commit 250ac35fd1511179d91a117b6298a4a81857b70d Author: nala Date: Mon Sep 28 10:01:09 2026 +0200 first commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..37cfad78 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..798a7e01 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..4da48a8f --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..ae67c4dd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 5 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..c879b56a --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d4b97fe5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..97b895e2 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +ignore-scripts=true diff --git a/README.md b/README.md new file mode 100644 index 00000000..fd5b8db8 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php new file mode 100644 index 00000000..3c7c00c8 --- /dev/null +++ b/app/Actions/Fortify/CreateNewUser.php @@ -0,0 +1,33 @@ + $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'], + ]); + } +} diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php new file mode 100644 index 00000000..8fda5ddd --- /dev/null +++ b/app/Actions/Fortify/ResetUserPassword.php @@ -0,0 +1,29 @@ + $input + */ + public function reset(User $user, array $input): void + { + Validator::make($input, [ + 'password' => $this->passwordRules(), + ])->validate(); + + $user->forceFill([ + 'password' => $input['password'], + ])->save(); + } +} diff --git a/app/Concerns/PasswordValidationRules.php b/app/Concerns/PasswordValidationRules.php new file mode 100644 index 00000000..7409ed8b --- /dev/null +++ b/app/Concerns/PasswordValidationRules.php @@ -0,0 +1,29 @@ +|string> + */ + protected function passwordRules(): array + { + return ['required', 'string', Password::default(), 'confirmed']; + } + + /** + * Get the validation rules used to validate the current password. + * + * @return array|string> + */ + protected function currentPasswordRules(): array + { + return ['required', 'string', 'current_password']; + } +} diff --git a/app/Concerns/ProfileValidationRules.php b/app/Concerns/ProfileValidationRules.php new file mode 100644 index 00000000..a9c069bd --- /dev/null +++ b/app/Concerns/ProfileValidationRules.php @@ -0,0 +1,51 @@ +|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|string> + */ + protected function nameRules(): array + { + return ['required', 'string', 'max:255']; + } + + /** + * Get the validation rules used to validate user emails. + * + * @return array|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), + ]; + } +} diff --git a/app/Console/Commands/AiBenchmark.php b/app/Console/Commands/AiBenchmark.php new file mode 100644 index 00000000..08beed53 --- /dev/null +++ b/app/Console/Commands/AiBenchmark.php @@ -0,0 +1,77 @@ +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(); + } + } +} diff --git a/app/Console/Commands/ImportInvestmentPortfolio.php b/app/Console/Commands/ImportInvestmentPortfolio.php new file mode 100644 index 00000000..b523967a --- /dev/null +++ b/app/Console/Commands/ImportInvestmentPortfolio.php @@ -0,0 +1,59 @@ +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; + } +} diff --git a/app/Console/Commands/InstallFeaturesCommand.php b/app/Console/Commands/InstallFeaturesCommand.php new file mode 100644 index 00000000..532a1a8a --- /dev/null +++ b/app/Console/Commands/InstallFeaturesCommand.php @@ -0,0 +1,116 @@ +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...', + ); + } +} diff --git a/app/Console/Commands/PortalReadiness.php b/app/Console/Commands/PortalReadiness.php new file mode 100644 index 00000000..6d286c2f --- /dev/null +++ b/app/Console/Commands/PortalReadiness.php @@ -0,0 +1,54 @@ += 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; + } +} diff --git a/app/Console/Commands/ReconcileInvestmentIdentity.php b/app/Console/Commands/ReconcileInvestmentIdentity.php new file mode 100644 index 00000000..cb39ffc6 --- /dev/null +++ b/app/Console/Commands/ReconcileInvestmentIdentity.php @@ -0,0 +1,47 @@ +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; + } +} diff --git a/app/Console/Commands/SuiteCrmProbe.php b/app/Console/Commands/SuiteCrmProbe.php new file mode 100644 index 00000000..50ed9d1d --- /dev/null +++ b/app/Console/Commands/SuiteCrmProbe.php @@ -0,0 +1,41 @@ +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; + } +} diff --git a/app/Http/Controllers/AiController.php b/app/Http/Controllers/AiController.php new file mode 100644 index 00000000..12b421dc --- /dev/null +++ b/app/Http/Controllers/AiController.php @@ -0,0 +1,212 @@ + 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]); + } +} diff --git a/app/Http/Controllers/Api/V1/HomeController.php b/app/Http/Controllers/Api/V1/HomeController.php new file mode 100644 index 00000000..de838a41 --- /dev/null +++ b/app/Http/Controllers/Api/V1/HomeController.php @@ -0,0 +1,62 @@ +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]); + } +} diff --git a/app/Http/Controllers/Api/V1/InterestRequestController.php b/app/Http/Controllers/Api/V1/InterestRequestController.php new file mode 100644 index 00000000..9c4ab1c0 --- /dev/null +++ b/app/Http/Controllers/Api/V1/InterestRequestController.php @@ -0,0 +1,100 @@ +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(); + } +} diff --git a/app/Http/Controllers/Api/V1/OpportunityController.php b/app/Http/Controllers/Api/V1/OpportunityController.php new file mode 100644 index 00000000..3748af98 --- /dev/null +++ b/app/Http/Controllers/Api/V1/OpportunityController.php @@ -0,0 +1,74 @@ +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]); + } +} diff --git a/app/Http/Controllers/Api/V1/SectorController.php b/app/Http/Controllers/Api/V1/SectorController.php new file mode 100644 index 00000000..cf6b7bbb --- /dev/null +++ b/app/Http/Controllers/Api/V1/SectorController.php @@ -0,0 +1,40 @@ + 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]); + } +} diff --git a/app/Http/Controllers/ClusterValueChainController.php b/app/Http/Controllers/ClusterValueChainController.php new file mode 100644 index 00000000..9d199b4e --- /dev/null +++ b/app/Http/Controllers/ClusterValueChainController.php @@ -0,0 +1,60 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 00000000..8677cd5c --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +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 */ + 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.'); + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 00000000..c4206b33 --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,33 @@ +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']), + ]]); + } +} diff --git a/app/Http/Controllers/EducationController.php b/app/Http/Controllers/EducationController.php new file mode 100644 index 00000000..49626c10 --- /dev/null +++ b/app/Http/Controllers/EducationController.php @@ -0,0 +1,153 @@ +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)]); + } +} diff --git a/app/Http/Controllers/EducationInvestmentController.php b/app/Http/Controllers/EducationInvestmentController.php new file mode 100644 index 00000000..001f85a8 --- /dev/null +++ b/app/Http/Controllers/EducationInvestmentController.php @@ -0,0 +1,60 @@ +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.'); + } +} diff --git a/app/Http/Controllers/InvestmentContextController.php b/app/Http/Controllers/InvestmentContextController.php new file mode 100644 index 00000000..a8135c30 --- /dev/null +++ b/app/Http/Controllers/InvestmentContextController.php @@ -0,0 +1,100 @@ + 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 $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)]); + } +} diff --git a/app/Http/Controllers/InvestmentDiscoveryController.php b/app/Http/Controllers/InvestmentDiscoveryController.php new file mode 100644 index 00000000..f44d8a92 --- /dev/null +++ b/app/Http/Controllers/InvestmentDiscoveryController.php @@ -0,0 +1,150 @@ +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 $filters + * @return Collection + */ + 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']); + } +} diff --git a/app/Http/Controllers/InvestmentLegalSourceController.php b/app/Http/Controllers/InvestmentLegalSourceController.php new file mode 100644 index 00000000..33a7fdae --- /dev/null +++ b/app/Http/Controllers/InvestmentLegalSourceController.php @@ -0,0 +1,65 @@ +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)]); + } +} diff --git a/app/Http/Controllers/InvestmentPortfolioController.php b/app/Http/Controllers/InvestmentPortfolioController.php new file mode 100644 index 00000000..e382b96d --- /dev/null +++ b/app/Http/Controllers/InvestmentPortfolioController.php @@ -0,0 +1,131 @@ +$left && $b->$right && DB::table($table)->where($left, $a->$left)->where($right, $b->$right)->whereNull('deleted_at')->exists()) { + return true; + } + } + } + + return false; + } + + /** @return Builder */ + 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.'); + } +} diff --git a/app/Http/Controllers/InvestmentStructureController.php b/app/Http/Controllers/InvestmentStructureController.php new file mode 100644 index 00000000..65666b2b --- /dev/null +++ b/app/Http/Controllers/InvestmentStructureController.php @@ -0,0 +1,106 @@ +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.'); + } +} diff --git a/app/Http/Controllers/InvestmentSubmissionController.php b/app/Http/Controllers/InvestmentSubmissionController.php new file mode 100644 index 00000000..7da038d1 --- /dev/null +++ b/app/Http/Controllers/InvestmentSubmissionController.php @@ -0,0 +1,336 @@ +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 */ + 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)]); + } +} diff --git a/app/Http/Controllers/InvestmentWorkspaceController.php b/app/Http/Controllers/InvestmentWorkspaceController.php new file mode 100644 index 00000000..22558952 --- /dev/null +++ b/app/Http/Controllers/InvestmentWorkspaceController.php @@ -0,0 +1,159 @@ + $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 $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)]); + } +} diff --git a/app/Http/Controllers/InvestmentZoneController.php b/app/Http/Controllers/InvestmentZoneController.php new file mode 100644 index 00000000..ed36f114 --- /dev/null +++ b/app/Http/Controllers/InvestmentZoneController.php @@ -0,0 +1,93 @@ +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'])); + } +} diff --git a/app/Http/Controllers/InvestmentZoneGeometryController.php b/app/Http/Controllers/InvestmentZoneGeometryController.php new file mode 100644 index 00000000..f751a48f --- /dev/null +++ b/app/Http/Controllers/InvestmentZoneGeometryController.php @@ -0,0 +1,125 @@ +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)]); + } +} diff --git a/app/Http/Controllers/InvestmentZoneLinkController.php b/app/Http/Controllers/InvestmentZoneLinkController.php new file mode 100644 index 00000000..2b532ba3 --- /dev/null +++ b/app/Http/Controllers/InvestmentZoneLinkController.php @@ -0,0 +1,73 @@ +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)]); + } +} diff --git a/app/Http/Controllers/InvestmentZoneRelationController.php b/app/Http/Controllers/InvestmentZoneRelationController.php new file mode 100644 index 00000000..8550c3c8 --- /dev/null +++ b/app/Http/Controllers/InvestmentZoneRelationController.php @@ -0,0 +1,75 @@ +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)]); + } +} diff --git a/app/Http/Controllers/InvestorWorkspaceController.php b/app/Http/Controllers/InvestorWorkspaceController.php new file mode 100644 index 00000000..7a598e31 --- /dev/null +++ b/app/Http/Controllers/InvestorWorkspaceController.php @@ -0,0 +1,86 @@ +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.'); + } +} diff --git a/app/Http/Controllers/PortalManagementController.php b/app/Http/Controllers/PortalManagementController.php new file mode 100644 index 00000000..986e8c6d --- /dev/null +++ b/app/Http/Controllers/PortalManagementController.php @@ -0,0 +1,198 @@ +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.'); + } +} diff --git a/app/Http/Controllers/PortalServicesController.php b/app/Http/Controllers/PortalServicesController.php new file mode 100644 index 00000000..d36b65fb --- /dev/null +++ b/app/Http/Controllers/PortalServicesController.php @@ -0,0 +1,201 @@ + $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)]); + } +} diff --git a/app/Http/Controllers/PublicPortalController.php b/app/Http/Controllers/PublicPortalController.php new file mode 100644 index 00000000..6fe103a1 --- /dev/null +++ b/app/Http/Controllers/PublicPortalController.php @@ -0,0 +1,44 @@ +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]); + } +} diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php new file mode 100644 index 00000000..d19f963d --- /dev/null +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -0,0 +1,62 @@ + $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('/'); + } +} diff --git a/app/Http/Controllers/Settings/SecurityController.php b/app/Http/Controllers/Settings/SecurityController.php new file mode 100644 index 00000000..6c422aa9 --- /dev/null +++ b/app/Http/Controllers/Settings/SecurityController.php @@ -0,0 +1,72 @@ + 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(); + } +} diff --git a/app/Http/Middleware/AiEnabled.php b/app/Http/Middleware/AiEnabled.php new file mode 100644 index 00000000..12877278 --- /dev/null +++ b/app/Http/Middleware/AiEnabled.php @@ -0,0 +1,17 @@ +cookie('appearance') ?? 'system'); + + return $next($request); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php new file mode 100644 index 00000000..2f0a7087 --- /dev/null +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -0,0 +1,51 @@ + + */ + 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', + ]; + } +} diff --git a/app/Http/Middleware/PrivateInvestmentResponse.php b/app/Http/Middleware/PrivateInvestmentResponse.php new file mode 100644 index 00000000..9a3cff2a --- /dev/null +++ b/app/Http/Middleware/PrivateInvestmentResponse.php @@ -0,0 +1,21 @@ +headers->set('Cache-Control', 'private, no-store'); + + return $response; + } +} diff --git a/app/Http/Middleware/RequireZoneMfa.php b/app/Http/Middleware/RequireZoneMfa.php new file mode 100644 index 00000000..539c3fc2 --- /dev/null +++ b/app/Http/Middleware/RequireZoneMfa.php @@ -0,0 +1,18 @@ +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); + } +} diff --git a/app/Http/Requests/Settings/PasswordUpdateRequest.php b/app/Http/Requests/Settings/PasswordUpdateRequest.php new file mode 100644 index 00000000..0e5ff2f4 --- /dev/null +++ b/app/Http/Requests/Settings/PasswordUpdateRequest.php @@ -0,0 +1,25 @@ +|string> + */ + public function rules(): array + { + return [ + 'current_password' => $this->currentPasswordRules(), + 'password' => $this->passwordRules(), + ]; + } +} diff --git a/app/Http/Requests/Settings/ProfileDeleteRequest.php b/app/Http/Requests/Settings/ProfileDeleteRequest.php new file mode 100644 index 00000000..aec9174f --- /dev/null +++ b/app/Http/Requests/Settings/ProfileDeleteRequest.php @@ -0,0 +1,24 @@ +|string> + */ + public function rules(): array + { + return [ + 'password' => $this->currentPasswordRules(), + ]; + } +} diff --git a/app/Http/Requests/Settings/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php new file mode 100644 index 00000000..e4eb8d8b --- /dev/null +++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php @@ -0,0 +1,22 @@ +|string> + */ + public function rules(): array + { + return $this->profileRules($this->user()->id); + } +} diff --git a/app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php b/app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php new file mode 100644 index 00000000..8a4c428d --- /dev/null +++ b/app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php @@ -0,0 +1,22 @@ +|string> + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/StoreInterestRequestRequest.php b/app/Http/Requests/StoreInterestRequestRequest.php new file mode 100644 index 00000000..a172211c --- /dev/null +++ b/app/Http/Requests/StoreInterestRequestRequest.php @@ -0,0 +1,60 @@ +> */ + 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.', + ]; + } +} diff --git a/app/Integrations/SuiteCrm/SuiteCrmClient.php b/app/Integrations/SuiteCrm/SuiteCrmClient.php new file mode 100644 index 00000000..ae5f0c87 --- /dev/null +++ b/app/Integrations/SuiteCrm/SuiteCrmClient.php @@ -0,0 +1,320 @@ + */ + public function find(string $module, string $id): array + { + $this->module($module); + $this->identifier($id); + + return $this->request('GET', '/module/'.$module.'/'.$id, [], 'find'); + } + + /** + * @param array $equals Exact-match filters; no arbitrary URLs. + * @return array + */ + 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 $attributes + * @return array + */ + 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 $attributes + * @return array + */ + 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 */ + 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 */ + 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 $data + * @return array + */ + 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 $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); + } +} diff --git a/app/Integrations/SuiteCrm/SuiteCrmException.php b/app/Integrations/SuiteCrm/SuiteCrmException.php new file mode 100644 index 00000000..5d348307 --- /dev/null +++ b/app/Integrations/SuiteCrm/SuiteCrmException.php @@ -0,0 +1,19 @@ + */ + 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)); + } +} diff --git a/app/Models/AiRun.php b/app/Models/AiRun.php new file mode 100644 index 00000000..ad56e817 --- /dev/null +++ b/app/Models/AiRun.php @@ -0,0 +1,15 @@ + 'encrypted:array', 'source_ids' => 'array', 'review_requested' => 'boolean']; +} diff --git a/app/Models/ClusterProject.php b/app/Models/ClusterProject.php new file mode 100644 index 00000000..16089956 --- /dev/null +++ b/app/Models/ClusterProject.php @@ -0,0 +1,5 @@ + 'immutable_datetime']; +} diff --git a/app/Models/DevelopmentCorridor.php b/app/Models/DevelopmentCorridor.php new file mode 100644 index 00000000..48e1674a --- /dev/null +++ b/app/Models/DevelopmentCorridor.php @@ -0,0 +1,5 @@ + 'boolean', + 'consented_at' => 'datetime', + 'metadata' => 'array', + 'reviewed_at' => 'datetime', + ]; + + /** @return BelongsTo */ + public function opportunity(): BelongsTo + { + return $this->belongsTo(Opportunity::class); + } +} diff --git a/app/Models/InvestmentDossier.php b/app/Models/InvestmentDossier.php new file mode 100644 index 00000000..db02299f --- /dev/null +++ b/app/Models/InvestmentDossier.php @@ -0,0 +1,16 @@ + $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']; +} diff --git a/app/Models/InvestmentGap.php b/app/Models/InvestmentGap.php new file mode 100644 index 00000000..338c3f71 --- /dev/null +++ b/app/Models/InvestmentGap.php @@ -0,0 +1,6 @@ + 'encrypted:array']; + } +} diff --git a/app/Models/InvestmentSubmission.php b/app/Models/InvestmentSubmission.php new file mode 100644 index 00000000..6e6331c0 --- /dev/null +++ b/app/Models/InvestmentSubmission.php @@ -0,0 +1,18 @@ + 'encrypted:array', 'version' => 'integer']; + } +} diff --git a/app/Models/InvestmentZone.php b/app/Models/InvestmentZone.php new file mode 100644 index 00000000..376bf181 --- /dev/null +++ b/app/Models/InvestmentZone.php @@ -0,0 +1,8 @@ + 'integer', 'published_at' => 'datetime']; +} diff --git a/app/Models/Investor.php b/app/Models/Investor.php new file mode 100644 index 00000000..f97003e6 --- /dev/null +++ b/app/Models/Investor.php @@ -0,0 +1,39 @@ + */ + 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 */ + protected function casts(): array + { + return [ + 'version_no' => 'integer', + 'created_at' => 'immutable_datetime', + 'updated_at' => 'immutable_datetime', + 'deleted_at' => 'immutable_datetime', + ]; + } +} diff --git a/app/Models/InvestorJourney.php b/app/Models/InvestorJourney.php new file mode 100644 index 00000000..a25cdeb9 --- /dev/null +++ b/app/Models/InvestorJourney.php @@ -0,0 +1,11 @@ + 'decimal:2']; + + /** @return BelongsTo */ + public function investor(): BelongsTo + { + return $this->belongsTo(Investor::class); + } + + /** @return BelongsTo */ + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_user_id'); + } + + /** @return HasMany */ + public function tasks(): HasMany + { + return $this->hasMany(CrmTask::class); + } +} diff --git a/app/Models/Occupation.php b/app/Models/Occupation.php new file mode 100644 index 00000000..5c27617a --- /dev/null +++ b/app/Models/Occupation.php @@ -0,0 +1,5 @@ + 'boolean', + 'published_at' => 'datetime', + 'last_validated_at' => 'datetime', + 'estimated_investment_amount' => 'decimal:2', + ]; + + /** @return BelongsTo */ + public function sector(): BelongsTo + { + return $this->belongsTo(Sector::class); + } + + /** @param Builder $query + * @return Builder + */ + public function scopePublished(Builder $query): Builder + { + return $query->where('status', 'published')->whereNotNull('published_at')->where('published_at', '<=', now()); + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php new file mode 100644 index 00000000..18c73898 --- /dev/null +++ b/app/Models/Project.php @@ -0,0 +1,44 @@ + */ + 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 */ + 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', + ]; + } +} diff --git a/app/Models/Sector.php b/app/Models/Sector.php new file mode 100644 index 00000000..79373a81 --- /dev/null +++ b/app/Models/Sector.php @@ -0,0 +1,28 @@ + 'boolean', + 'published_at' => 'datetime', + ]; + + /** @return HasMany */ + public function opportunities(): HasMany + { + return $this->hasMany(Opportunity::class); + } +} diff --git a/app/Models/Skill.php b/app/Models/Skill.php new file mode 100644 index 00000000..f8abb8a6 --- /dev/null +++ b/app/Models/Skill.php @@ -0,0 +1,5 @@ + */ + use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + /* @chisel-2fa */ + 'two_factor_confirmed_at' => 'datetime', + /* @end-chisel-2fa */ + ]; + } +} diff --git a/app/Models/ValueChain.php b/app/Models/ValueChain.php new file mode 100644 index 00000000..76a1041d --- /dev/null +++ b/app/Models/ValueChain.php @@ -0,0 +1,6 @@ +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, + ); + } +} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php new file mode 100644 index 00000000..a8fc9bb7 --- /dev/null +++ b/app/Providers/FortifyServiceProvider.php @@ -0,0 +1,117 @@ +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 */ + } +} diff --git a/app/Services/Ai/AiGateway.php b/app/Services/Ai/AiGateway.php new file mode 100644 index 00000000..a223718b --- /dev/null +++ b/app/Services/Ai/AiGateway.php @@ -0,0 +1,68 @@ + $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; + }); + } +} diff --git a/app/Services/Ai/AssistiveRules.php b/app/Services/Ai/AssistiveRules.php new file mode 100644 index 00000000..737ca9e3 --- /dev/null +++ b/app/Services/Ai/AssistiveRules.php @@ -0,0 +1,103 @@ + $input + * @return list> + */ + 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> */ + 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> */ + 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> */ + 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()); + } +} diff --git a/app/Services/Ai/EvidenceRepository.php b/app/Services/Ai/EvidenceRepository.php new file mode 100644 index 00000000..98c8df31 --- /dev/null +++ b/app/Services/Ai/EvidenceRepository.php @@ -0,0 +1,55 @@ +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 $filters + * @return list> + */ + 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> */ + 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 $ids */ + public function allStillEligible(array $ids): bool + { + return $ids === [] || $this->eligible()->whereIn('s.id', $ids)->count() === count(array_unique($ids)); + } +} diff --git a/app/Services/Ai/HybridFusion.php b/app/Services/Ai/HybridFusion.php new file mode 100644 index 00000000..079a4049 --- /dev/null +++ b/app/Services/Ai/HybridFusion.php @@ -0,0 +1,33 @@ + $lexical + * @param list $vector + * @param list $authorised + * @return list + */ + 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))); + } +} diff --git a/app/Services/Ai/SourceIngestion.php b/app/Services/Ai/SourceIngestion.php new file mode 100644 index 00000000..310ae289 --- /dev/null +++ b/app/Services/Ai/SourceIngestion.php @@ -0,0 +1,51 @@ + $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; + }); + } +} diff --git a/app/Services/ConvertInterestToLead.php b/app/Services/ConvertInterestToLead.php new file mode 100644 index 00000000..18b1db8e --- /dev/null +++ b/app/Services/ConvertInterestToLead.php @@ -0,0 +1,70 @@ +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; + }); + } +} diff --git a/app/Services/EducationGapCalculator.php b/app/Services/EducationGapCalculator.php new file mode 100644 index 00000000..edf3ca82 --- /dev/null +++ b/app/Services/EducationGapCalculator.php @@ -0,0 +1,27 @@ + 'expired', 'people' => null]; + } + if (! $comparable) { + return ['state' => 'incomparable', 'people' => null]; + } + if ($demand === null || $available === null) { + return ['state' => 'insufficient_data', 'people' => null]; + } + + return ['state' => 'observed_unvalidated', 'people' => max(0, $demand - $available)]; + } +} diff --git a/app/Services/IdempotencyConflict.php b/app/Services/IdempotencyConflict.php new file mode 100644 index 00000000..ea4757b0 --- /dev/null +++ b/app/Services/IdempotencyConflict.php @@ -0,0 +1,5 @@ + $payload Validated business fields only. + * @param Closure(): InterestRequest $create + */ + public function receive(#[SensitiveParameter] array $payload, #[SensitiveParameter] ?string $key, Closure $create): InterestRequest + { + $canonical = array_replace(['phone' => null, 'opportunityReference' => null, 'investmentRange' => null], $payload); + $canonical['email'] = strtolower(trim($canonical['email'])); + $canonical['fullName'] = trim($canonical['fullName']); + $canonical['message'] = trim($canonical['message']); + $canonical['privacyConsent'] = true; // FormRequest requires acceptance. + ksort($canonical); + $payloadHash = hash_hmac('sha256', json_encode($canonical, JSON_THROW_ON_ERROR), config()->string('app.key')); + $keyHash = $key === null ? null : hash('sha256', strtolower($key)); + + return DB::transaction(function () use ($keyHash, $payloadHash, $create): InterestRequest { + if ($keyHash !== null) { + // Serialise concurrent submissions of this key across all web workers. + DB::select('select pg_advisory_xact_lock(250925, hashtext(?))', [$keyHash]); + $receipt = DB::table('interest_submission_receipts')->where('key_hash', $keyHash)->first(); + if ($receipt) { + if (! hash_equals($receipt->payload_hash, $payloadHash)) { + throw new IdempotencyConflict; + } + + return InterestRequest::withTrashed()->where('id', $receipt->interest_request_id)->firstOrFail(); + } + } + $interest = $create(); + $integrationId = (string) Str::uuid(); + $interest->forceFill(['integration_id' => $integrationId])->save(); + DB::table('outbox_events')->insert([ + 'source_system' => 'portal-intake', + 'external_id' => 'ExpressionOfInterestSubmitted:'.$integrationId, + 'aggregate_type' => 'interest_request', 'aggregate_id' => $integrationId, + 'event_type' => 'ExpressionOfInterestSubmitted', 'data_classification' => 'confidential', + 'correlation_id' => $integrationId, 'status' => 'pending', + 'payload' => json_encode(['schema_version' => 1, 'interest_id' => $integrationId], JSON_THROW_ON_ERROR), + ]); + if ($keyHash !== null) { + DB::table('interest_submission_receipts')->insert([ + 'key_hash' => $keyHash, 'payload_hash' => $payloadHash, + 'interest_request_id' => $interest->id, 'created_at' => now(), + ]); + } + + return $interest; + }); + } +} diff --git a/app/Services/InvestmentCatalogue.php b/app/Services/InvestmentCatalogue.php new file mode 100644 index 00000000..100e6ab7 --- /dev/null +++ b/app/Services/InvestmentCatalogue.php @@ -0,0 +1,170 @@ + ['cluster_id', 'economic_clusters'], 'corridors' => ['corridor_id', 'development_corridors'], + 'projects' => ['project_id', 'projects'], 'locations' => ['geo_location_id', 'geo_locations']]; + + /** @return array */ + public function filters(Request $request): array + { + return $request->validate(['q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'], + 'province' => ['nullable', 'string', 'max:100'], 'district' => ['nullable', 'string', 'max:100'], + 'cluster_id' => ['nullable', 'uuid'], 'corridor_id' => ['nullable', 'uuid'], 'project_id' => ['nullable', 'uuid'], + 'geo_location_id' => ['nullable', 'uuid'], 'education' => ['nullable', 'boolean'], + 'zee_id' => ['nullable', 'uuid'], 'zfi_id' => ['nullable', 'uuid'], 'park_id' => ['nullable', 'uuid'], + 'dimension' => ['nullable', 'in:cluster,corridor,zee,zfi'], 'view' => ['nullable', 'in:grid,list,map,combined'], + 'maturity_level' => ['nullable', 'integer', 'between:1,6'], 'facets' => ['nullable', 'array', 'max:20'], 'facets.*' => ['integer', 'min:1'], + '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'], + 'before' => ['nullable', 'date_format:Y-m-d'], 'sort' => ['nullable', 'in:recent,title,amount_asc,amount_desc'], + 'per_page' => ['nullable', 'integer', 'between:1,50'], 'page' => ['nullable', 'integer', 'min:1']]); + } + + /** + * @param array $filters + * @return Builder + */ + public function query(array $filters): Builder + { + $query = Opportunity::published()->with('sector:id,name,slug'); + if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) { + $query->whereIn('opportunities.id', app(InvestmentZones::class)->links()->join('investment_item_opportunities as mapping', 'mapping.item_id', '=', 'zl.item_id') + ->join('investment_zones as z', 'z.id', '=', 'zl.zone_id')->where('z.category', $filters['dimension'])->select('mapping.opportunity_id')); + } + foreach (['cluster' => 'clusters', 'corridor' => 'corridors'] as $dimension => $relation) { + if (($filters['dimension'] ?? null) === $dimension) { + $foreign = $dimension.'_id'; + $query->whereIn('opportunities.id', DB::table('opportunity_'.$relation)->whereIn($foreign, InvestmentPortfolioController::visible()->whereNotNull($foreign)->select($foreign))->select('opportunity_id')); + } + } + foreach (['zee_id' => 'zee', 'zfi_id' => 'zfi', 'park_id' => 'industrial_park'] as $field => $category) { + if (! empty($filters[$field])) { + $query->whereIn('opportunities.id', app(InvestmentZones::class)->links() + ->join('investment_item_opportunities as mapping', 'mapping.item_id', '=', 'zl.item_id') + ->join('investment_zones as z', 'z.id', '=', 'zl.zone_id')->where('z.category', $category) + ->where('zl.zone_id', $filters[$field])->select('mapping.opportunity_id')); + } + } + if (! empty($filters['q'])) { + $term = '%'.$filters['q'].'%'; + $query->where(fn ($q) => $q->where('title', 'ilike', $term)->orWhere('summary', 'ilike', $term)->orWhere('reference', 'ilike', $term) + ->orWhere('province', 'ilike', $term)->orWhereHas('sector', fn ($s) => $s->where('name', 'ilike', $term))); + } + if (! empty($filters['sector'])) { + $query->whereHas('sector', fn ($s) => $s->where('slug', $filters['sector'])); + } + foreach (['province', 'district', 'maturity_level'] as $field) { + if (! empty($filters[$field])) { + $query->where($field, $filters[$field]); + } + } + foreach (self::RELATIONS as $relation => [$foreign, $table]) { + if (! empty($filters[$foreign])) { + $query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link')->join($table.' as target', 'target.id', '=', 'link.'.$foreign) + ->whereColumn('link.opportunity_id', 'opportunities.id')->where('link.'.$foreign, $filters[$foreign])->whereNull('target.deleted_at') + ->when($relation === 'locations', fn ($q) => $q->where('target.data_classification', 'public'), + fn ($q) => $q->whereIn('target.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign)))); + } + } + if (! empty($filters['education'])) { + $query->where(fn ($q) => $q->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_profiles')->whereColumn('opportunity_id', 'opportunities.id')->where('is_education', true)) + ->orWhereExists(fn ($sub) => $sub->selectRaw('1')->from('education_investment_proposals')->whereColumn('opportunity_id', 'opportunities.id')->whereNull('deleted_at'))); + } + foreach ($filters['facets'] ?? [] as $option) { + $query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_facets')->whereColumn('opportunity_id', 'opportunities.id')->where('option_id', $option)); + } + foreach (['min_amount' => '>=', 'max_amount' => '<='] as $field => $operator) { + if (isset($filters[$field])) { + $query->where('estimated_investment_amount', $operator, $filters[$field]); + } + } + if (! empty($filters['currency'])) { + $query->where('estimated_investment_currency', $filters['currency']); + } + if (! empty($filters['before'])) { + $query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_profiles')->whereColumn('opportunity_id', 'opportunities.id')->where('target_date', '<=', $filters['before'])); + } + $sort = $filters['sort'] ?? 'recent'; + + // Monetary sorting stays grouped by currency; no undocumented FX conversion. + return match ($sort) { + 'title' => $query->orderBy('title')->orderBy('id'), + 'amount_asc', 'amount_desc' => $query->orderBy('estimated_investment_currency')->orderBy('estimated_investment_amount', $sort === 'amount_asc' ? 'asc' : 'desc')->orderBy('id'), + default => $query->orderByDesc('published_at')->orderByDesc('id'), + }; + } + + /** @return array */ + public function references(bool $management = false): array + { + $result = ['options' => DB::table('investment_reference_options')->when(! $management, fn ($q) => $q->where('active', true))->orderBy('sort_order')->orderBy('label')->get(), + 'sectors' => DB::table('sectors')->where('is_active', true)->whereNull('deleted_at')->orderBy('name')->get(['id', 'name', 'slug'])]; + foreach (self::RELATIONS as $relation => [$foreign, $table]) { + $query = DB::table($table)->whereNull('deleted_at'); + if (! $management) { + if ($relation === 'locations') { + $query->where('data_classification', 'public'); + } else { + $query->whereIn('id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign)); + } + } + $result[$relation] = $query->orderBy('name')->get(['id', 'name']); + } + + $result['zones'] = app(InvestmentZones::class)->visible()->orderBy('name')->get(['id', 'name', 'category']); + + return $result; + } + + /** @return array */ + public function context(int $id, bool $management = false): array + { + $result = ['profile' => DB::table('opportunity_profiles')->where('opportunity_id', $id)->first(), + 'facets' => DB::table('opportunity_facets as f')->join('investment_reference_options as o', 'o.id', '=', 'f.option_id') + ->where('f.opportunity_id', $id)->get(['o.id', 'o.group', 'o.label'])]; + foreach (self::RELATIONS as $relation => [$foreign, $table]) { + $query = DB::table('opportunity_'.$relation.' as l')->join($table.' as t', 't.id', '=', 'l.'.$foreign)->where('l.opportunity_id', $id)->whereNull('t.deleted_at'); + if (! $management) { + if ($relation === 'locations') { + $query->where('t.data_classification', 'public'); + } else { + $query->whereIn('t.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign)); + } + } + $result[$relation] = $query->get(['t.id', 't.name']); + } + + $result['zones'] = 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('m.opportunity_id', $id) + ->get(['z.id', 'z.name', 'z.category', 'zl.association_type', 'zl.source_reference', 'zl.valid_from', 'zl.valid_until']); + + return $result; + } + + /** + * @param array $filters + * @return array + */ + public function map(array $filters): array + { + $ids = $this->query($filters)->reorder()->select('opportunities.id'); + $points = DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id') + ->join('geo_geometries as shape', 'shape.geo_location_id', '=', 'g.id')->join('opportunities as o', 'o.id', '=', 'l.opportunity_id') + ->whereIn('o.id', $ids)->where('g.data_classification', 'public')->where('shape.data_classification', 'public') + ->whereNull('g.deleted_at')->whereNull('shape.deleted_at')->where('shape.geometry_type', 'point')->limit(1000) + ->get(['o.id', 'o.title', 'o.slug', 'g.name', DB::raw('ST_AsGeoJSON(shape.geom) as geometry')]); + + return ['type' => 'FeatureCollection', 'limit' => 1000, 'features' => $points->map(fn ($p) => ['type' => 'Feature', + 'geometry' => json_decode($p->geometry, true), 'properties' => ['id' => $p->id, 'title' => $p->title, 'location' => $p->name, 'url' => '/oportunidades/'.$p->slug]])->all()]; + } +} diff --git a/app/Services/InvestmentIdentity.php b/app/Services/InvestmentIdentity.php new file mode 100644 index 00000000..1110edfe --- /dev/null +++ b/app/Services/InvestmentIdentity.php @@ -0,0 +1,27 @@ +lockForUpdate()->firstOrFail(); + $mapping = DB::table('investment_item_opportunities')->where('opportunity_id', $id)->first(); + if ($mapping) { + return $mapping->item_id; + } + $item = InvestmentItem::create(['code' => 'opportunity:'.$id, 'title' => $opportunity->title, 'kind' => 'opportunity']); + DB::table('investment_item_opportunities')->insert(['item_id' => $item->id, 'opportunity_id' => $id, + 'mapping_evidence' => 'Direct correspondence to existing opportunity primary key; no inferred merge or change of validation status.', + 'created_at' => now(), 'updated_at' => now()]); + + return $item->id; + }); + } +} diff --git a/app/Services/InvestmentWorkspace.php b/app/Services/InvestmentWorkspace.php new file mode 100644 index 00000000..a7132741 --- /dev/null +++ b/app/Services/InvestmentWorkspace.php @@ -0,0 +1,38 @@ + $payload */ + public function notify(int $userId, InvestmentSubmission $submission, string $kind, string $eventKey, array $payload = []): void + { + DB::table('investment_notifications')->insertOrIgnore(['id' => (string) Str::uuid(), 'user_id' => $userId, + 'submission_id' => $submission->id, 'kind' => $kind, 'event_key' => $eventKey, + 'payload' => Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)), 'created_at' => now()]); + } + + public function snapshot(InvestmentSubmission $submission): void + { + $snapshot = ['payload' => $submission->payload, 'status' => $submission->status, + 'zone_origin' => DB::table('investment_submission_zones')->where('submission_id', $submission->id)->first(['zone_id', 'zone_version', 'intent']), + 'needs' => InvestmentSpecificNeed::where('submission_id', $submission->id)->get()->toArray(), + 'attachment_ids' => DB::table('investment_submission_attachments')->where('submission_id', $submission->id)->pluck('id')->all()]; + DB::table('investment_submission_revisions')->insert(['submission_id' => $submission->id, 'version' => $submission->version, + 'snapshot' => Crypt::encryptString(json_encode($snapshot, JSON_THROW_ON_ERROR)), 'created_at' => now()]); + } + + /** @return array */ + public function account(int $userId): array + { + $row = DB::table('investment_accounts')->where('user_id', $userId)->first(); + + return ['version' => $row->version ?? 0, 'payload' => $row ? json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR) : []]; + } +} diff --git a/app/Services/InvestmentZoneAccess.php b/app/Services/InvestmentZoneAccess.php new file mode 100644 index 00000000..840f3a41 --- /dev/null +++ b/app/Services/InvestmentZoneAccess.php @@ -0,0 +1,14 @@ +email), config('investment-zones.'.$role, []), true), 403, + 'O perfil não está designado para esta revisão. A configuração de funções é feita pela administração.'); + } +} diff --git a/app/Services/InvestmentZones.php b/app/Services/InvestmentZones.php new file mode 100644 index 00000000..be2fe8c8 --- /dev/null +++ b/app/Services/InvestmentZones.php @@ -0,0 +1,94 @@ +where('zl.validation_status', 'validated') + ->whereIn('zl.zone_id', $this->visible()->select('investment_zones.id')) + ->whereDate('zl.valid_from', '<=', today())->where(fn ($q) => $q->whereNull('zl.valid_until')->orWhereDate('zl.valid_until', '>=', today())) + ->where(function ($q) { + $q->whereNull('zl.legal_source_id')->orWhereExists(fn ($s) => $s->selectRaw('1')->from('investment_legal_sources as ls') + ->whereColumn('ls.id', 'zl.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()))); + }); + } + + /** @return Builder */ + public function visible(): Builder + { + return InvestmentZone::query()->where('publication_status', 'published')->where('validation_status', 'validated') + ->whereExists(fn ($q) => $q->selectRaw('1')->from('investment_legal_sources as source') + ->whereColumn('source.id', 'investment_zones.legal_source_id')->where('source.validation_status', 'validated') + ->whereDate('source.valid_from', '<=', today()) + ->where(fn ($q) => $q->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', today()))) + ->whereExists(fn ($q) => $q->selectRaw('1')->from('organizations')->whereColumn('organizations.id', 'investment_zones.operator_id')->whereNull('organizations.deleted_at')); + } + + /** @param array $data */ + public function save(?string $id, array $data, int $actor): InvestmentZone + { + return DB::transaction(function () use ($id, $data, $actor) { + $zone = $id ? InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail() : new InvestmentZone; + abort_if($id && (int) $zone->version_no !== (int) $data['version_no'], 409, 'Versão alterada. Recarregue antes de guardar.'); + $version = $id ? (int) $zone->version_no + 1 : 1; + unset($data['version_no']); + $zone->fill([...$data, 'editor_id' => $actor, 'reviewer_id' => null, 'published_at' => null, + 'version_no' => $version, 'publication_status' => 'draft', 'validation_status' => 'pending'])->save(); + $this->snapshot($zone, $actor, 'saved'); + + return $zone; + }); + } + + public function publish(string $id, int $version, int $actor): InvestmentZone + { + return DB::transaction(function () use ($id, $version, $actor) { + $zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail(); + abort_if((int) $zone->version_no !== $version, 409, 'Versão alterada. Recarregue antes de aprovar.'); + abort_if((int) $zone->editor_id === $actor, 403, 'A revisão exige outro gestor autorizado.'); + $source = DB::table('investment_legal_sources')->where('id', $zone->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 válida e revista em falta.'); + abort_unless($zone->category !== 'pending' && $zone->operator_id && trim((string) $zone->name_en) !== '' + && trim((string) $zone->description) !== '' && trim((string) $zone->description_en) !== '', 422, 'Categoria, operador e conteúdo PT/EN são obrigatórios.'); + abort_unless(DB::table('organizations')->where('id', $zone->operator_id)->whereNull('deleted_at')->exists(), 422, 'Operador indisponível.'); + $zone->fill(['version_no' => $version + 1, 'reviewer_id' => $actor, 'publication_status' => 'published', + 'validation_status' => 'validated', 'published_at' => now()])->save(); + $this->snapshot($zone, $actor, 'published'); + + return $zone; + }); + } + + public function suspend(string $id, int $version, int $actor): InvestmentZone + { + return DB::transaction(function () use ($id, $version, $actor) { + $zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail(); + abort_if((int) $zone->version_no !== $version, 409, 'Versão alterada.'); + $zone->fill(['version_no' => $version + 1, 'publication_status' => 'suspended'])->save(); + $this->snapshot($zone, $actor, 'suspended'); + + return $zone; + }); + } + + private function snapshot(InvestmentZone $zone, int $actor, string $action): void + { + $json = $zone->toJson(JSON_THROW_ON_ERROR); + DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id, + 'version_no' => $zone->version_no, 'actor_id' => $actor, 'action' => $action, + 'snapshot' => Crypt::encryptString($json), 'sha256' => hash('sha256', $json)]); + DB::table('audit_events')->insert(['source_system' => 'investment-zones', 'entity_type' => 'investment_zones', + 'entity_id' => $zone->id, 'actor_user_id' => $actor, 'action' => $action, + 'after_data' => json_encode(['version_no' => $zone->version_no, 'sha256' => hash('sha256', $json)], JSON_THROW_ON_ERROR)]); + } +} diff --git a/app/Services/PortalAudit.php b/app/Services/PortalAudit.php new file mode 100644 index 00000000..c78a6d01 --- /dev/null +++ b/app/Services/PortalAudit.php @@ -0,0 +1,41 @@ + $data */ + public static function save(Model $record, array $data, int $actor): void + { + DB::transaction(function () use ($record, $data, $actor) { + $created = ! $record->exists; + if (! $created) { + $locked = $record->newQuery()->where($record->getKeyName(), $record->getKey())->lockForUpdate()->firstOrFail(); + $record->setRawAttributes($locked->getAttributes(), true); + } + // Exclude free text and personal data from the immutable event payload. + $fields = ['version_no', 'status', 'is_active', 'is_featured', 'sort_order', 'sector_id', 'maturity_level', + 'estimated_investment_amount', 'estimated_investment_currency', 'owner_user_id', 'assignee_user_id', 'due_at', + 'cluster_id', 'project_id', 'corridor_id', 'dossier_id', 'interest_request_id', 'publication_status', 'editor_id', 'reviewer_id', 'role', 'stage', 'value_chain_id', 'from_value_chain_id', 'to_value_chain_id', 'link_type', 'deleted_at']; + $before = array_intersect_key($record->getAttributes(), array_flip($fields)); + $record->fill($data); + $changed = array_keys($record->getDirty()); + $record->save(); + if (! $changed) { + return; + } + DB::table('audit_events')->insert([ + 'source_system' => 'portal-management', 'actor_user_id' => $actor, + 'entity_type' => $record->getTable(), 'entity_id' => Str::isUuid((string) $record->getKey()) ? $record->getKey() : null, + 'action' => $created ? 'created' : 'updated', + 'before_data' => json_encode(['record_id' => $record->getKey(), 'values' => $before], JSON_THROW_ON_ERROR), + 'after_data' => json_encode(['record_id' => $record->getKey(), 'changed_fields' => $changed, + 'values' => array_intersect_key($record->getAttributes(), array_flip($fields))], JSON_THROW_ON_ERROR), + ]); + }); + } +} diff --git a/artisan b/artisan new file mode 100644 index 00000000..c35e31d6 --- /dev/null +++ b/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 00000000..e95b56b4 --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,31 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); + + $middleware->web(append: [ + HandleAppearance::class, + HandleInertiaRequests::class, + AddLinkHeadersForPreloadedAssets::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + $exceptions->shouldRenderJsonWhen( + fn (Request $request) => $request->is('api/*') || $request->expectsJson(), + ); + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 00000000..5ffd7697 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,9 @@ + 'resources/js/pages/auth/login.tsx', + 'register' => 'resources/js/pages/auth/register.tsx', + 'welcome' => 'resources/js/pages/welcome.tsx', + 'profile' => 'resources/js/pages/settings/profile.tsx', + 'security' => 'resources/js/pages/settings/security.tsx', + 'verify_email' => 'resources/js/pages/auth/verify-email.tsx', + 'two_factor_challenge' => 'resources/js/pages/auth/two-factor-challenge.tsx', + 'confirm_password' => 'resources/js/pages/auth/confirm-password.tsx', + 'auth_types' => 'resources/js/types/auth.ts', + + 'two_factor_files' => [ + 'resources/js/components/manage-two-factor.tsx', + 'resources/js/components/two-factor-setup-modal.tsx', + 'resources/js/components/two-factor-recovery-codes.tsx', + 'resources/js/components/ui/input-otp.tsx', + 'resources/js/hooks/use-two-factor-auth.ts', + ], + + 'two_factor_otp_package' => 'input-otp', + + 'passkey_files' => [ + 'resources/js/components/passkey-item.tsx', + 'resources/js/components/passkey-register.tsx', + 'resources/js/components/passkey-verify.tsx', + 'resources/js/components/manage-passkeys.tsx', + ], +]; diff --git a/chisel.php b/chisel.php new file mode 100644 index 00000000..da95dfec --- /dev/null +++ b/chisel.php @@ -0,0 +1,301 @@ +run(function ($type, $line) use ($logger) { + $logger->line($line); + }); + + if ($process->isSuccessful()) { + $logger->success(implode(' ', $command)); + + return $process; + } + + $logger->error(implode(' ', $command)); + $logger->error('Error output: '.trim($process->getErrorOutput())); + $logger->error('Chisel: Your project may be in a partially-modified state — review the output above before continuing.'); + + return $process; + }, + ); + + if (! $process->isSuccessful()) { + exit($process->getExitCode()); + } +} + +function chiselSkipsNode(): bool +{ + return filter_var( + $_ENV['LARAVEL_INSTALLER_NO_NODE'] + ?? $_SERVER['LARAVEL_INSTALLER_NO_NODE'] + ?? getenv('LARAVEL_INSTALLER_NO_NODE'), + FILTER_VALIDATE_BOOL, + ); +} + +function chiselRemoveNpmPackages(Chisel $c, string ...$packages): void +{ + if (! chiselSkipsNode()) { + $c->npm()->remove(...$packages); + + return; + } + + foreach ($packages as $package) { + $c->file('package.json')->removeLinesContaining('"'.$package.'":'); + } +} + +/** + * Framework-specific filenames are supplied by the sibling chisel-paths.php + * that ships with each Inertia kit (React/Svelte/Vue). After build both files + * land in the project root. + * + * @var array{ + * login: string, + * register: string, + * welcome: string, + * profile: string, + * security: string, + * verify_email: string, + * two_factor_challenge: string, + * confirm_password: string, + * auth_types: string, + * two_factor_files: list, + * two_factor_otp_package: ?string, + * passkey_files: list, + * } $paths + */ +$paths = require __DIR__.'/chisel-paths.php'; + +return Chisel::script(__DIR__) + ->questions([ + Question::multiselect( + name: 'auth_features', + label: 'Which authentication features would you like to enable?', + options: [ + 'email-verification' => 'Email verification', + 'registration' => 'Registration', + '2fa' => 'Two-factor authentication', + 'passkeys' => 'Passkeys', + 'password-confirmation' => 'Password confirmation', + ], + default: ['email-verification', 'registration', '2fa', 'passkeys', 'password-confirmation'], + hint: 'Use space to select, enter to confirm.', + ), + ]) + ->selected( + 'auth_features', + 'registration', + then: function (Chisel $c) use ($paths) { + $c->files( + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + $paths['login'], + $paths['welcome'], + )->removeSectionMarkers('registration'); + }, + else: function (Chisel $c) use ($paths) { + $c->file('config/fortify.php')->removeSection('registration'); + + $c->files( + 'app/Providers/FortifyServiceProvider.php', + $paths['login'], + $paths['welcome'], + )->removeSection('registration'); + + $c->files( + 'app/Actions/Fortify/CreateNewUser.php', + 'app/Http/Responses/RegisterResponse.php', + $paths['register'], + 'tests/Feature/Auth/RegistrationTest.php', + )->delete(); + }, + ) + ->selected( + 'auth_features', + 'email-verification', + then: function (Chisel $c) use ($paths) { + $c->files( + 'config/fortify.php', + $paths['profile'], + 'app/Providers/FortifyServiceProvider.php', + )->removeSectionMarkers('email-verification'); + }, + else: function (Chisel $c) use ($paths) { + $c->php('app/Models/User.php') + ->removeImport('Illuminate\Contracts\Auth\MustVerifyEmail') + ->removeInterface('MustVerifyEmail'); + + $c->files( + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + $paths['profile'], + )->removeSection('email-verification'); + + $c->files( + 'app/Http/Responses/VerifyEmailResponse.php', + $paths['verify_email'], + 'tests/Feature/Auth/EmailVerificationTest.php', + 'tests/Feature/Auth/VerificationNotificationTest.php', + )->delete(); + }, + ) + ->selected( + 'auth_features', + '2fa', + then: function (Chisel $c) use ($paths) { + $c->files( + 'app/Models/User.php', + 'database/factories/UserFactory.php', + $paths['security'], + $paths['auth_types'], + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + 'app/Http/Controllers/Settings/SecurityController.php', + )->removeSectionMarkers('2fa'); + }, + else: function (Chisel $c) use ($paths) { + $c->php('app/Models/User.php') + ->removeImport('Laravel\Fortify\TwoFactorAuthenticatable') + ->removeTrait('TwoFactorAuthenticatable'); + + $c->files( + 'app/Models/User.php', + 'database/factories/UserFactory.php', + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + 'app/Http/Controllers/Settings/SecurityController.php', + $paths['security'], + $paths['auth_types'], + )->removeSection('2fa'); + + if ($paths['two_factor_otp_package'] !== null) { + chiselRemoveNpmPackages($c, $paths['two_factor_otp_package']); + } + + $c->files(...[ + $paths['two_factor_challenge'], + ...$paths['two_factor_files'], + 'database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php', + 'tests/Feature/Auth/TwoFactorChallengeTest.php', + ])->delete(); + }, + ) + ->selected( + 'auth_features', + 'passkeys', + then: function (Chisel $c) use ($paths) { + $c->files( + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + 'app/Http/Controllers/Settings/SecurityController.php', + 'routes/settings.php', + 'tests/Feature/Auth/AuthenticationTest.php', + 'tests/Feature/Settings/SecurityTest.php', + $paths['auth_types'], + $paths['security'], + $paths['login'], + $paths['confirm_password'], + )->removeSectionMarkers('passkeys'); + }, + else: function (Chisel $c) use ($paths) { + $c->php('app/Models/User.php') + ->removeImport('Laravel\Fortify\PasskeyAuthenticatable') + ->removeImport('Laravel\Fortify\Contracts\PasskeyUser') + ->removeTrait('PasskeyAuthenticatable') + ->removeInterface('PasskeyUser'); + + $c->files( + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + 'app/Http/Controllers/Settings/SecurityController.php', + 'routes/settings.php', + 'tests/Feature/Auth/AuthenticationTest.php', + 'tests/Feature/Settings/SecurityTest.php', + $paths['auth_types'], + $paths['security'], + $paths['login'], + $paths['confirm_password'], + )->removeSection('passkeys'); + + chiselRemoveNpmPackages($c, '@laravel/passkeys'); + + $c->files(...[ + ...$paths['passkey_files'], + 'app/Http/Responses/PasskeyLoginResponse.php', + 'database/migrations/2024_01_01_000000_create_passkeys_table.php', + ])->delete(); + }, + ) + ->selectedAny( + 'auth_features', + ['2fa', 'passkeys'], + then: function (Chisel $c) use ($paths) { + $c->file($paths['security']) + ->removeSectionMarkers('2fa-or-passkeys'); + }, + else: function (Chisel $c) use ($paths) { + $c->file($paths['security']) + ->removeSection('2fa-or-passkeys'); + }, + ) + ->selected( + 'auth_features', + 'password-confirmation', + then: function (Chisel $c) { + $c->files( + 'app/Providers/FortifyServiceProvider.php', + 'routes/settings.php', + 'tests/Feature/Settings/SecurityTest.php', + )->removeSectionMarkers('password-confirmation'); + }, + else: function (Chisel $c) use ($paths) { + $c->file('config/fortify.php') + ->replace("'confirmPassword' => true,", "'confirmPassword' => false,"); + + $c->files( + 'app/Providers/FortifyServiceProvider.php', + 'routes/settings.php', + 'tests/Feature/Settings/SecurityTest.php', + )->removeSection('password-confirmation'); + + $c->files( + $paths['confirm_password'], + 'tests/Feature/Auth/PasswordConfirmationTest.php', + )->delete(); + }, + ) + ->apply(function (Chisel $c): void { + $c->file('composer.json') + ->removeLinesContaining('"@php artisan install:features --ansi"'); + + chiselRun(['composer', 'lint'], 'Composer Lint'); + chiselRun(['php', 'artisan', 'wayfinder:generate', '--with-form', '--no-interaction'], 'Generate Wayfinder Resources'); + + if (! chiselSkipsNode()) { + $c->npm()->run('check:fix'); + } + + $c->files( + 'app/Console/Commands/InstallFeaturesCommand.php', + 'chisel.php', + 'chisel-paths.php', + )->delete(); + }); diff --git a/components.json b/components.json new file mode 100644 index 00000000..4a304d0e --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "resources/css/app.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..5d86c12d --- /dev/null +++ b/composer.json @@ -0,0 +1,118 @@ +{ + "$schema": "https://getcomposer.org/schema.json", + "name": "laravel/react-starter-kit", + "type": "project", + "description": "The skeleton application for the Laravel framework.", + "keywords": [ + "laravel", + "framework" + ], + "license": "MIT", + "require": { + "php": "^8.3", + "inertiajs/inertia-laravel": "^3.0", + "laravel/chisel": "^0.1.0", + "laravel/fortify": "^1.37.2", + "laravel/framework": "^13.17", + "laravel/tinker": "^3.0", + "laravel/wayfinder": "^0.1.14" + }, + "require-dev": { + "fakerphp/faker": "^1.24", + "larastan/larastan": "^3.9", + "laravel/pail": "^1.2.5", + "laravel/pao": "^1.0.6", + "laravel/pint": "^1.27", + "laravel/sail": "^1.53", + "mockery/mockery": "^1.6", + "nunomaduro/collision": "^8.9.3", + "phpunit/phpunit": "^12.5.23" + }, + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Factories\\": "database/factories/", + "Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "setup": [ + "composer install", + "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", + "@php artisan key:generate", + "@php artisan migrate --force", + "npm install", + "npm run build" + ], + "dev": [ + "Composer\\Config::disableProcessTimeout", + "@php artisan dev" + ], + "lint": [ + "pint --parallel" + ], + "lint:check": [ + "pint --parallel --test" + ], + "ci:check": [ + "Composer\\Config::disableProcessTimeout", + "npm run check", + "npm run types:check", + "@test" + ], + "types:check": [ + "phpstan analyse" + ], + "test": [ + "@php artisan config:clear --ansi", + "@lint:check", + "@types:check", + "@php artisan test" + ], + "post-autoload-dump": [ + "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", + "@php artisan package:discover --ansi" + ], + "post-update-cmd": [ + "@php artisan install:features --ansi", + "@php artisan vendor:publish --tag=laravel-assets --ansi --force" + ], + "post-root-package-install": [ + "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" + ], + "post-create-project-cmd": [ + "@php artisan key:generate --ansi", + "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", + "@php artisan migrate --graceful --ansi" + ], + "pre-package-uninstall": [ + "Illuminate\\Foundation\\ComposerScripts::prePackageUninstall" + ] + }, + "extra": { + "laravel": { + "dont-discover": [], + "installer": { + "post-create-project": [ + "@php artisan install:features --ansi" + ] + } + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "php-http/discovery": true + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 00000000..3afec21d --- /dev/null +++ b/composer.lock @@ -0,0 +1,10269 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "d0a5f283f588eb7a0f25e28797e62117", + "packages": [ + { + "name": "bacon/bacon-qr-code", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || ^11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "spatie/pixelmatch-php": "^1.2.0", + "squizlabs/php_codesniffer": "^3.9" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" + }, + "time": "2026-04-05T21:06:35+00:00" + }, + { + "name": "brick/math", + "version": "0.18.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.18.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-06-14T18:21:03+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2026-09-06T14:11:38+00:00" + }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:06:52+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "8.2.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "93939470950a9b11e2e84204166ef5e048c55fe4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/93939470950a9b11e2e84204166ef5e048c55fe4", + "reference": "93939470950a9b11e2e84204166ef5e048c55fe4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^3.0.2", + "guzzlehttp/psr7": "^3.1", + "php": "^7.4 || ^8.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "4.0.1", + "guzzlehttp/test-server": "^1.0.1", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/8.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-09-06T13:55:09+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/42118e66a53c492effaf92bc357e931985d5c6f9", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-24T10:00:26+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-factory-implementation": "1.1", + "psr/http-message-implementation": "2.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "php-http/psr7-integration-tests": "^1.5.1", + "phpunit/phpunit": "^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/3.1.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T11:02:13+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/7a466ad606491eb6528c717482f7cca77f1851f3", + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "symfony/polyfill-php80": "^1.25" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^9.6.34", + "uri-template/tests": "1.0.2" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-08-24T17:13:02+00:00" + }, + { + "name": "inertiajs/inertia-laravel", + "version": "v3.3.3", + "source": { + "type": "git", + "url": "https://github.com/inertiajs/inertia-laravel.git", + "reference": "42dccee77d7df6b2e152965434a89cafc35a2961" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/42dccee77d7df6b2e152965434a89cafc35a2961", + "reference": "42dccee77d7df6b2e152965434a89cafc35a2961", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel/framework": "^11.35|^12.0|^13.0", + "php": "^8.2.0", + "symfony/console": "^7.0|^8.0" + }, + "conflict": { + "laravel/boost": "<2.5.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.15.2|^8.0", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.16", + "mockery/mockery": "^1.3.3", + "orchestra/testbench": "^9.2|^10.0|^11.0", + "phpunit/phpunit": "^11.5|^12.0" + }, + "suggest": { + "ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Inertia\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "./helpers.php" + ], + "psr-4": { + "Inertia\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Reinink", + "email": "jonathan@reinink.ca", + "homepage": "https://reinink.ca" + } + ], + "description": "The Laravel adapter for Inertia.js.", + "keywords": [ + "inertia", + "laravel" + ], + "support": { + "issues": "https://github.com/inertiajs/inertia-laravel/issues", + "source": "https://github.com/inertiajs/inertia-laravel/tree/v3.3.3" + }, + "time": "2026-09-04T13:56:39+00:00" + }, + { + "name": "laravel/chisel", + "version": "v0.1.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/chisel.git", + "reference": "c2ac11cd1c6b763b412aa26b9d95fce70a489d31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/chisel/zipball/c2ac11cd1c6b763b412aa26b9d95fce70a489d31", + "reference": "c2ac11cd1c6b763b412aa26b9d95fce70a489d31", + "shasum": "" + }, + "require": { + "illuminate/process": "^10.20|^11.0|^12.0|^13.0", + "nikic/php-parser": "^5.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.24.0", + "pestphp/pest": "^4.1.0", + "phpstan/phpstan": "^2.1.26", + "rector/rector": "^2.1.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laravel\\Chisel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Toolkit for building scripts that remove unwanted code, files, and dependencies.", + "keywords": [ + "chisel", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/chisel/issues", + "source": "https://github.com/laravel/chisel/tree/v0.1.1" + }, + "time": "2026-05-13T21:29:24+00:00" + }, + { + "name": "laravel/fortify", + "version": "v1.39.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/fortify.git", + "reference": "b1fc50707bbe007fd92165d8b7d460ab549b355a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/fortify/zipball/b1fc50707bbe007fd92165d8b7d460ab549b355a", + "reference": "b1fc50707bbe007fd92165d8b7d460ab549b355a", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "laravel/passkeys": "^0.2.0", + "php": "^8.2", + "pragmarx/google2fa": "^9.0" + }, + "require-dev": { + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^2.2.6" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Fortify\\FortifyServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Fortify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Backend controllers and scaffolding for Laravel authentication.", + "keywords": [ + "auth", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/fortify/issues", + "source": "https://github.com/laravel/fortify" + }, + "time": "2026-08-23T07:46:41+00:00" + }, + { + "name": "laravel/framework", + "version": "v13.30.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "718d17db56861e0a49f644217c8853dab1bff8ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/718d17db56861e0a49f644217c8853dab1bff8ce", + "reference": "718d17db56861e0a49f644217c8853dab1bff8ce", + "shasum": "" + }, + "require": { + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.9 || ^3.0", + "guzzlehttp/uri-template": "^1.0 || ^2.0", + "laravel/prompts": "^0.3.11", + "laravel/serializable-closure": "^2.0.10", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.10", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/http-message": "^1.0 || ^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/error-handler": "^7.4.0 || ^8.0.0", + "symfony/finder": "^7.4.0 || ^8.0.0", + "symfony/http-foundation": "^7.4.0 || ^8.0.0", + "symfony/http-kernel": "^7.4.0 || ^8.0.0", + "symfony/mailer": "^7.4.0 || ^8.0.0", + "symfony/mime": "^7.4.0 || ^8.0.0", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36", + "symfony/process": "^7.4.5 || ^8.0.5", + "symfony/routing": "^7.4.0 || ^8.0.0", + "symfony/uid": "^7.4.0 || ^8.0.0", + "symfony/var-dumper": "^7.4.0 || ^8.0.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1 || 2.0", + "psr/log-implementation": "1.0 || 2.0 || 3.0", + "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/image": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "intervention/image": "^4.0", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^11.0.0", + "pda/pheanstalk": "^7.0.0 || ^8.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", + "predis/predis": "^2.3 || ^3.0", + "rector/rector": "2.6.3", + "resend/resend-php": "^1.0", + "symfony/cache": "^7.4.0 || ^8.0.0", + "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", + "symfony/translation": "^7.4.0 || ^8.0.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "intervention/image": "Required to use the image processing features (^4.0).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", + "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-09-01T21:42:30+00:00" + }, + { + "name": "laravel/passkeys", + "version": "v0.2.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/passkeys-server.git", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/passkeys-server/zipball/a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/http": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "web-auth/webauthn-lib": "5.3.x" + }, + "require-dev": { + "laravel/pint": "^1.28.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passkeys\\PasskeysServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passkeys\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Passwordless authentication using WebAuthn/passkeys for Laravel", + "homepage": "https://github.com/laravel/passkeys-server", + "keywords": [ + "Authentication", + "Passwordless", + "laravel", + "passkeys", + "webauthn" + ], + "support": { + "issues": "https://github.com/laravel/passkeys-server/issues", + "source": "https://github.com/laravel/passkeys-server" + }, + "time": "2026-05-18T16:26:00+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.24", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.24" + }, + "time": "2026-08-20T12:55:36+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.16", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-08-18T20:28:54+00:00" + }, + { + "name": "laravel/tinker", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "4faba77764bd33411735936acdf30446d058c78b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", + "shasum": "" + }, + "require": { + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5|^11.5" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v3.0.2" + }, + "time": "2026-03-17T14:54:13+00:00" + }, + { + "name": "laravel/wayfinder", + "version": "v0.1.21", + "source": { + "type": "git", + "url": "https://github.com/laravel/wayfinder.git", + "reference": "a85a996cea189f59cac14854f8b13319a29007f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/wayfinder/zipball/a85a996cea189f59cac14854f8b13319a29007f3", + "reference": "a85a996cea189f59cac14854f8b13319a29007f3", + "shasum": "" + }, + "require": { + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/filesystem": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "phpstan/phpdoc-parser": "^2.3" + }, + "conflict": { + "laravel/boost": "<2.5.0" + }, + "require-dev": { + "laravel/pint": "^1.21", + "orchestra/testbench": "^11.0|^10.1|^9.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Wayfinder\\WayfinderServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Wayfinder\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Generate TypeScript representations of your Laravel actions and routes.", + "homepage": "https://github.com/laravel/wayfinder", + "keywords": [ + "laravel", + "php", + "routes", + "typescript" + ], + "support": { + "issues": "https://github.com/laravel/wayfinder/issues", + "source": "https://github.com/laravel/wayfinder" + }, + "time": "2026-08-04T21:55:43+00:00" + }, + { + "name": "league/commonmark", + "version": "2.10.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e", + "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.11-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-09-07T13:44:26+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.36.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.36.0" + }, + "time": "2026-09-02T08:00:27+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.35.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" + }, + "time": "2026-08-12T13:29:21+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.11.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "147f303310f06334f03f409e49d7ad1e275ff05a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/147f303310f06334f03f409e49d7ad1e275ff05a", + "reference": "147f303310f06334f03f409e49d7ad1e275ff05a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.11.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-09-02T12:39:56+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.13.2", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-08-08T11:40:35+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.6", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "c54350438cd6914616f790a49cb424605f421562" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.6" + }, + "time": "2026-08-16T21:58:41+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2026-08-24T00:54:40+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.5", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" + }, + "time": "2026-08-31T16:05:28+00:00" + }, + { + "name": "pragmarx/google2fa", + "version": "v9.1.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "f00bc788c555adfb6765c437ff3538e59cd88af1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/f00bc788c555adfb6765c437ff3538e59cd88af1", + "reference": "f00bc788c555adfb6765c437ff3538e59cd88af1", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.0|^2.0", + "phpunit/phpunit": "~9|~10|~11|~12|~13", + "psalm/plugin-phpunit": "^0.19|^0.20", + "vimeo/psalm": "^5.26|^6.13" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "MFA", + "Two Factor Authentication", + "google-authenticator", + "google2fa", + "hotp", + "otp", + "rfc4226", + "rfc6238", + "totp" + ], + "support": { + "docs": "https://github.com/antonioribeiro/google2fa#readme", + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "security": "https://github.com/antonioribeiro/google2fa/security/policy", + "source": "https://github.com/antonioribeiro/google2fa" + }, + "time": "2026-08-15T13:22:01+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.24", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + }, + "time": "2026-06-29T15:41:09+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.3", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "shasum": "" + }, + "require": { + "brick/math": ">=0.8.16 <=0.18", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.3" + }, + "time": "2026-06-18T03:57:49+00:00" + }, + { + "name": "spomky-labs/cbor-php", + "version": "3.3.4", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/cbor-php.git", + "reference": "1cac24eeae4be47122d505542a170623b2a35ea3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/1cac24eeae4be47122d505542a170623b2a35ea3", + "reference": "1cac24eeae4be47122d505542a170623b2a35ea3", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20", + "ext-mbstring": "*", + "php": ">=8.0" + }, + "require-dev": { + "ext-json": "*", + "roave/security-advisories": "dev-latest", + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", + "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "CBOR\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors" + } + ], + "description": "CBOR Encoder/Decoder for PHP", + "keywords": [ + "Concise Binary Object Representation", + "RFC7049", + "cbor" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/cbor-php/issues", + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.3.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-08-31T18:59:25+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.6.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "8f333bebe104ea24f1a160a6fb69e2dead4f1882" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/8f333bebe104ea24f1a160a6fb69e2dead4f1882", + "reference": "8f333bebe104ea24f1a160a6fb69e2dead4f1882", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0 || ^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering", + "ext-sodium": "To verify Ed25519 signatures where the OpenSSL extension has no EdDSA" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-09-08T07:15:08+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/console", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/eb7d9957d66739649e931ce7a9d05dab69f8abac", + "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:18:42+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/08e2905152a39cf3fd1745d83f8c483e258887d9", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:06:25+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", + "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^7.4|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "8d7acede2b2ae07605783d1c43e49b5767036474" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/8d7acede2b2ae07605783d1c43e49b5767036474", + "reference": "8d7acede2b2ae07605783d1c43e49b5767036474", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T12:16:08+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/093b78326f649c3a9db922b9f17123b6aeb3b8fb", + "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T20:10:55+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", + "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/flex": "<2.10", + "symfony/http-client-contracts": "<2.5", + "symfony/serializer": "<7.4.15|>=8.0,<8.0.15|>=8.1,<8.1.2", + "symfony/translation-contracts": "<2.5", + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", + "twig/twig": "<3.21" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T21:40:49+00:00" + }, + { + "name": "symfony/mailer", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "89f43137da74b8f1aab37c99926482b7084f51b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/89f43137da74b8f1aab37c99926482b7084f51b9", + "reference": "89f43137da74b8f1aab37c99926482b7084f51b9", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.4.1", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/mime", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", + "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/serializer": "^7.4.17|^8.1.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-22T09:06:25+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-24T10:51:20+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php82", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php82\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-02T13:42:24+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "d863f5e70d7c87abb906ac11b61f83036093000b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/d863f5e70d7c87abb906ac11b61f83036093000b", + "reference": "d863f5e70d7c87abb906ac11b61f83036093000b", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/property-access", + "version": "v8.1.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "1a41232c678972b93ce499a504e19ea09dfcd0b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/1a41232c678972b93ce499a504e19ea09dfcd0b2", + "reference": "1a41232c678972b93ce499a504e19ea09dfcd0b2", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/property-info": "^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v8.1.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-30T12:40:56+00:00" + }, + { + "name": "symfony/property-info", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "d25e1dabbaff3f91bb15ba68328cf4fbd1c43c42" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/d25e1dabbaff3f91bb15ba68328cf4fbd1c43c42", + "reference": "d25e1dabbaff3f91bb15ba68328cf4fbd1c43c42", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T08:48:43+00:00" + }, + { + "name": "symfony/routing", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/3c188091b6b4fa2e4bc83a135caede12deb8576c", + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-17T13:18:34+00:00" + }, + { + "name": "symfony/serializer", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "931151b12d6e2d4996ce2ce8e7b180d35455f37a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/931151b12d6e2d4996ce2ce8e7b180d35455f37a", + "reference": "931151b12d6e2d4996ce2ce8e7b180d35455f37a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-access": "<8.1", + "symfony/property-info": "<7.4.15", + "symfony/type-info": "<7.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^8.1", + "symfony/property-info": "^7.4.15|~8.0.15|^8.1.2", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T08:48:47+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:35:25+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/type-info", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "ceb48db5b38d6a48640c414be0c69d53980ae5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/ceb48db5b38d6a48640c414be0c69d53980ae5c5", + "reference": "ceb48db5b38d6a48640c414be0c69d53980ae5c5", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/uid", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "a08aef47989093f32fe50fd11859be1b427df389" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/a08aef47989093f32fe50fd11859be1b427df389", + "reference": "a08aef47989093f32fe50fd11859be1b427df389", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-11T13:39:01+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "3783365b58972f4779254d98372af80fbf15e170" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/3783365b58972f4779254d98372af80fbf15e170", + "reference": "3783365b58972f4779254d98372af80fbf15e170", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T20:10:55+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.2", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2026-08-24T18:07:49+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "web-auth/cose-lib", + "version": "4.7.1", + "source": { + "type": "git", + "url": "https://github.com/web-auth/cose-lib.git", + "reference": "e698b150049141f2f9ec03d2a4d681a2d020ab83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/e698b150049141f2f9ec03d2a4d681a2d020ab83", + "reference": "e698b150049141f2f9ec03d2a4d681a2d020ab83", + "shasum": "" + }, + "require": { + "brick/math": "^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.1", + "spomky-labs/pki-framework": "^1.0" + }, + "require-dev": { + "spomky-labs/cbor-php": "^3.2.2" + }, + "suggest": { + "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", + "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", + "spomky-labs/cbor-php": "For COSE Signature support" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cose\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/cose/contributors" + } + ], + "description": "CBOR Object Signing and Encryption (COSE) For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "COSE", + "RFC8152" + ], + "support": { + "issues": "https://github.com/web-auth/cose-lib/issues", + "source": "https://github.com/web-auth/cose-lib/tree/4.7.1" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-08-30T13:53:57+00:00" + }, + { + "name": "web-auth/webauthn-lib", + "version": "5.3.8", + "source": { + "type": "git", + "url": "https://github.com/web-auth/webauthn-lib.git", + "reference": "85d8ae791c87a34be91a7ff5cdb5606f67dedaab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/85d8ae791c87a34be91a7ff5cdb5606f67dedaab", + "reference": "85d8ae791c87a34be91a7ff5cdb5606f67dedaab", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "paragonie/constant_time_encoding": "^2.6|^3.0", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", + "psr/clock": "^1.0", + "psr/event-dispatcher": "^1.0", + "psr/log": "^1.0|^2.0|^3.0", + "spomky-labs/cbor-php": "^3.0", + "spomky-labs/pki-framework": "^1.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^3.2", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "web-auth/cose-lib": "^4.2.3" + }, + "suggest": { + "psr/log-implementation": "Recommended to receive logs from the library", + "symfony/event-dispatcher": "Recommended to use dispatched events", + "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/web-auth/webauthn-framework", + "name": "web-auth/webauthn-framework" + } + }, + "autoload": { + "psr-4": { + "Webauthn\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/webauthn-library/contributors" + } + ], + "description": "FIDO2/Webauthn Support For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "FIDO2", + "fido", + "webauthn" + ], + "support": { + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.8" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-08-30T14:20:17+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-dom": "*", + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" + }, + "time": "2026-03-17T11:56:53+00:00" + }, + { + "name": "iamcal/sql-parser", + "version": "v0.7", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.7" + }, + "time": "2026-01-28T22:20:33+00:00" + }, + { + "name": "larastan/larastan", + "version": "v3.11.0", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "9baa74074f17cc70feaef31616a06ea0faeebba5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/9baa74074f17cc70feaef31616a06ea0faeebba5", + "reference": "9baa74074f17cc70feaef31616a06ea0faeebba5", + "shasum": "" + }, + "require": { + "iamcal/sql-parser": "^0.7.0", + "illuminate/console": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/container": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/database": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/http": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", + "php": "^8.2", + "phpstan/phpstan": "^2.2.2" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", + "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.3.0" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.11.0" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2026-09-01T16:35:48+00:00" + }, + { + "name": "laravel/agent-detector", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/agent-detector.git", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/agent-detector/zipball/90694b9256099591cf9e55d08c18ba7a00bf099f", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f", + "shasum": "" + }, + "require": { + "php": "^8.2.0" + }, + "require-dev": { + "laravel/pint": "^1.24.0", + "pestphp/pest": "^3.8.5|^4.1.0", + "pestphp/pest-plugin-type-coverage": "^3.0|^4.0.2", + "phpstan/phpstan": "^2.1.26", + "rector/rector": "^2.1.7", + "symfony/var-dumper": "^7.3.3" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Laravel\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Detect if code is running in an AI agent or automated development environment", + "homepage": "https://github.com/laravel/agent-detector", + "keywords": [ + "Agent", + "ai", + "automation", + "claude", + "cursor", + "detection", + "devin", + "php" + ], + "support": { + "issues": "https://github.com/laravel/agent-detector/issues", + "source": "https://github.com/laravel/agent-detector" + }, + "time": "2026-04-29T18:32:34+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pao", + "version": "v1.1.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/pao.git", + "reference": "620424f1ce744d96937fb6be60a94793e79b67ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pao/zipball/620424f1ce744d96937fb6be60a94793e79b67ad", + "reference": "620424f1ce744d96937fb6be60a94793e79b67ad", + "shasum": "" + }, + "require": { + "laravel/agent-detector": "^2.0.2", + "php": "^8.3" + }, + "conflict": { + "laravel/framework": "<12.0.0", + "nunomaduro/collision": "<8.9.3", + "pestphp/pest": "<4.6.3 || >=6.0.0", + "phpunit/phpunit": "<12.5.23 || >=13.0.0 <13.1.7 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.20.0", + "laravel/pint": "^1.30.5", + "orchestra/testbench": "^10.11.0 || ^11.2.0", + "pestphp/pest": "^4.7.8 || ^5.1.4", + "pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.2", + "phpstan/phpstan": "^2.2.13", + "rector/rector": "^2.6.6", + "symfony/process": "^7.4.13 || ^8.1.6", + "symfony/var-dumper": "^7.4.15 || ^8.1.6" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Laravel\\Pao\\Drivers\\Pest\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Laravel\\Pao\\Laravel\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Laravel\\Pao\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Agent-optimized output for PHP testing tools", + "keywords": [ + "Agent", + "PHPStan", + "ai", + "dev", + "paratest", + "pest", + "php", + "phpunit", + "rector", + "testing" + ], + "support": { + "issues": "https://github.com/laravel/pao/issues", + "source": "https://github.com/laravel/pao" + }, + "time": "2026-09-07T14:48:06+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "0730363418ad06a7b267d8f2a4f87eed0ab7f610" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/0730363418ad06a7b267d8f2a4f87eed0ab7f610", + "reference": "0730363418ad06a7b267d8f2a4f87eed0ab7f610", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.3.0" + }, + "require-dev": { + "composer/semver": "^3.4.4", + "friendsofphp/php-cs-fixer": "^3.95.24", + "illuminate/view": "^13.30.1", + "larastan/larastan": "^3.11.0", + "laravel-zero/framework": "^13.0.0", + "laravel/agent-detector": "^2.0.2", + "laravel/prompts": "^0.3.24", + "mockery/mockery": "^1.6.15", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^4.7.8" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-09-07T15:03:30+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.67.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "639e03ac12cf23def171770bcab05758045b2642" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/639e03ac12cf23def171770bcab05758045b2642", + "reference": "639e03ac12cf23def171770bcab05758045b2642", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2026-08-12T13:55:56+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.15", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0 || ^3.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2026-08-19T19:37:52+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.5", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.14 || ^8.1.1" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-07-15T19:09:14+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.2.13", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260", + "reference": "9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-09-03T20:38:19+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "186dab580576598076de6818596d12b61801880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.28" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-06-01T13:24:19+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:40:53+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.34", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f", + "reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.7", + "phpunit/php-file-iterator": "^6.0.2", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.1", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.34" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-08-27T08:38:28+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:35:54+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T01:03:44+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.3" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 00000000..1f8dd277 --- /dev/null +++ b/config/app.php @@ -0,0 +1,126 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')), + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache", "array" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 00000000..d7568ff1 --- /dev/null +++ b/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 00000000..d7eec61a --- /dev/null +++ b/config/cache.php @@ -0,0 +1,136 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "storage", "octane", + | "session", "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'storage' => [ + 'driver' => 'storage', + 'disk' => env('CACHE_STORAGE_DISK'), + 'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + + /* + |-------------------------------------------------------------------------- + | Serializable Classes + |-------------------------------------------------------------------------- + | + | This value determines the classes that can be unserialized from cache + | storage. By default, no PHP classes will be unserialized from your + | cache to prevent gadget chain attacks if your APP_KEY is leaked. + | + */ + + 'serializable_classes' => false, + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 00000000..abbb88e3 --- /dev/null +++ b/config/database.php @@ -0,0 +1,184 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/config/education.php b/config/education.php new file mode 100644 index 00000000..fcd9e2e1 --- /dev/null +++ b/config/education.php @@ -0,0 +1,3 @@ + (bool) env('EDUCATION_ENABLED', false)]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 00000000..6cfdfb39 --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,80 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim((string) env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/fortify.php b/config/fortify.php new file mode 100644 index 00000000..086787f8 --- /dev/null +++ b/config/fortify.php @@ -0,0 +1,191 @@ + 'web', + + /* + |-------------------------------------------------------------------------- + | Fortify Password Broker + |-------------------------------------------------------------------------- + | + | Here you may specify which password broker Fortify can use when a user + | is resetting their password. This configured value should match one + | of your password brokers setup in your "auth" configuration file. + | + */ + + 'passwords' => 'users', + + /* + |-------------------------------------------------------------------------- + | Username / Email + |-------------------------------------------------------------------------- + | + | This value defines which model attribute should be considered as your + | application's "username" field. Typically, this might be the email + | address of the users but you are free to change this value here. + | + | Out of the box, Fortify expects forgot password and reset password + | requests to have a field named 'email'. If the application uses + | another name for the field you may define it below as needed. + | + */ + + 'username' => 'email', + + 'email' => 'email', + + /* + |-------------------------------------------------------------------------- + | Lowercase Usernames + |-------------------------------------------------------------------------- + | + | This value defines whether usernames should be lowercased before saving + | them in the database, as some database system string fields are case + | sensitive. You may disable this for your application if necessary. + | + */ + + 'lowercase_usernames' => true, + + /* + |-------------------------------------------------------------------------- + | Home Path + |-------------------------------------------------------------------------- + | + | Here you may configure the path where users will get redirected during + | authentication or password reset when the operations are successful + | and the user is authenticated. You are free to change this value. + | + */ + + 'home' => '/dashboard', + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Prefix / Subdomain + |-------------------------------------------------------------------------- + | + | Here you may specify which prefix Fortify will assign to all the routes + | that it registers with the application. If necessary, you may change + | subdomain under which all of the Fortify routes will be available. + | + */ + + 'prefix' => '', + + 'domain' => null, + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Middleware + |-------------------------------------------------------------------------- + | + | Here you may specify which middleware Fortify will assign to the routes + | that it registers with the application. If necessary, you may change + | these middleware but typically this provided default is preferred. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | By default, Fortify will throttle logins to five requests per minute for + | every email and IP address combination. However, if you would like to + | specify a custom rate limiter to call then you may specify it here. + | + */ + + 'limiters' => [ + 'login' => 'login', + /* @chisel-2fa */ + 'two-factor' => 'two-factor', + /* @end-chisel-2fa */ + /* @chisel-passkeys */ + 'passkeys' => 'passkeys', + /* @end-chisel-passkeys */ + ], + + /* + |-------------------------------------------------------------------------- + | Register View Routes + |-------------------------------------------------------------------------- + | + | Here you may specify if the routes returning views should be disabled as + | you may not need them when building your own application. This may be + | especially true if you're writing a custom single-page application. + | + */ + + 'views' => true, + + /* @chisel-passkeys */ + /* + |-------------------------------------------------------------------------- + | Passkeys + |-------------------------------------------------------------------------- + | + | These settings configure Fortify's passkey (WebAuthn) support. + | + */ + + 'passkeys' => [ + 'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST), + 'allowed_origins' => [config('app.url')], + 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')), + 'timeout' => 60000, + ], + /* @end-chisel-passkeys */ + + /* + |-------------------------------------------------------------------------- + | Features + |-------------------------------------------------------------------------- + | + | Some of the Fortify features are optional. You may disable the features + | by removing them from this array. You're free to only remove some of + | these features, or you can even remove all of these if you need to. + | + */ + + 'features' => [ + /* @chisel-registration */ + Features::registration(), + /* @end-chisel-registration */ + Features::resetPasswords(), + /* @chisel-email-verification */ + Features::emailVerification(), + /* @end-chisel-email-verification */ + /* @chisel-2fa */ + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + // 'window' => 0 + ]), + /* @end-chisel-2fa */ + /* @chisel-passkeys */ + Features::passkeys([ + 'confirmPassword' => true, + ]), + /* @end-chisel-passkeys */ + ], + +]; diff --git a/config/inertia.php b/config/inertia.php new file mode 100644 index 00000000..70f6b9b7 --- /dev/null +++ b/config/inertia.php @@ -0,0 +1,70 @@ + [ + 'enabled' => true, + 'url' => 'http://127.0.0.1:13714', + // 'bundle' => base_path('bootstrap/ssr/ssr.mjs'), + + ], + + /* + |-------------------------------------------------------------------------- + | Pages + |-------------------------------------------------------------------------- + | + | These options configure how Inertia discovers page components on the + | filesystem. The paths and extensions are used to locate components + | when rendering responses and during testing assertions. + | + */ + + 'pages' => [ + + 'paths' => [ + resource_path('js/pages'), + ], + + 'extensions' => [ + 'js', + 'jsx', + 'svelte', + 'ts', + 'tsx', + 'vue', + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Testing + |-------------------------------------------------------------------------- + | + | The values described here are used to locate Inertia components on the + | filesystem. For instance, when using `assertInertia`, the assertion + | attempts to locate the component as a file relative to the paths. + | + */ + + 'testing' => [ + + 'ensure_pages_exist' => true, + + ], + +]; diff --git a/config/investment-zones.php b/config/investment-zones.php new file mode 100644 index 00000000..8501bc26 --- /dev/null +++ b/config/investment-zones.php @@ -0,0 +1,9 @@ + array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_ZONE_LEGAL_REVIEWERS', '')))))), + 'gis_reviewers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_ZONE_GIS_REVIEWERS', '')))))), + 'publishers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_ZONE_PUBLISHERS', '')))))), +]; diff --git a/config/investmoz_ai.php b/config/investmoz_ai.php new file mode 100644 index 00000000..82d893fc --- /dev/null +++ b/config/investmoz_ai.php @@ -0,0 +1,16 @@ + (bool) env('INVESTMOZ_AI_ENABLED', false), + 'mode' => 'evidence_only', + 'rules_version' => 'investmoz-rules-1', + 'prompt_version' => 'evidence-extract-1', + 'max_text_bytes' => 200000, + 'chunk_characters' => 1800, + 'retrieval_limit' => 5, + 'capabilities' => ['assistant', 'search', 'recommend', 'classify', 'quality', 'sla', 'summary'], + // No model credentials, external endpoint or training enabled by default. + 'classification' => [ + 'next_action' => 'Rever campos declarados e confirmar responsável antes de encaminhar.', + ], +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 00000000..97bdc039 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,140 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "monthly", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'max_files' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'monthly' => [ + 'driver' => 'monthly', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'max_files' => 3, + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 00000000..8a1e56b8 --- /dev/null +++ b/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => (int) env('MAIL_TIMEOUT', 15), + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/config/portal.php b/config/portal.php new file mode 100644 index 00000000..a4877104 --- /dev/null +++ b/config/portal.php @@ -0,0 +1,17 @@ + [ + 'email' => env('PORTAL_SUPPORT_EMAIL'), + 'phone' => env('PORTAL_SUPPORT_PHONE'), + 'address' => env('PORTAL_SUPPORT_ADDRESS'), + 'hours' => env('PORTAL_SUPPORT_HOURS'), + ], + 'notifications_enabled' => env('PORTAL_NOTIFICATIONS_ENABLED', false), + 'notification_recipients' => array_values(array_filter(array_map('trim', explode(',', (string) env('PORTAL_NOTIFICATION_RECIPIENTS', ''))))), + // Explicit access list; public registration never grants management access. + 'managers' => array_values(array_filter(array_map( + fn ($email) => strtolower(trim($email)), + explode(',', (string) env('PORTAL_MANAGER_EMAILS', '')), + ))), +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 00000000..79c2c0a2 --- /dev/null +++ b/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 00000000..ec226a40 --- /dev/null +++ b/config/services.php @@ -0,0 +1,38 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 00000000..e0c0eaca --- /dev/null +++ b/config/session.php @@ -0,0 +1,233 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session', + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + + /* + |-------------------------------------------------------------------------- + | Session Serialization + |-------------------------------------------------------------------------- + | + | This value controls the serialization strategy for session data, which + | is JSON by default. Setting this to "php" allows the storage of PHP + | objects in the session but can make an application vulnerable to + | "gadget chain" serialization attacks if the APP_KEY is leaked. + | + | Supported: "json", "php" + | + */ + + 'serialization' => 'json', + +]; diff --git a/config/suitecrm.php b/config/suitecrm.php new file mode 100644 index 00000000..36414f69 --- /dev/null +++ b/config/suitecrm.php @@ -0,0 +1,17 @@ + (bool) env('SUITECRM_ENABLED', false), + // URL of the legacy application containing /Api, not the V8 endpoint itself. + 'base_url' => env('SUITECRM_BASE_URL', ''), + 'client_id' => env('SUITECRM_CLIENT_ID', ''), + 'client_secret' => env('SUITECRM_CLIENT_SECRET', ''), + 'cache_store' => env('SUITECRM_CACHE_STORE', 'database'), + 'timeout' => 10, + 'connect_timeout' => 3, + 'read_retries' => 2, + 'retry_delay_ms' => 250, + 'circuit_threshold' => 5, + 'circuit_cooldown' => 60, + 'modules' => ['Accounts', 'Contacts', 'Leads', 'Opportunities', 'Campaigns', 'Meetings', 'Tasks', 'Cases', 'Notes', 'Documents'], +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 00000000..9b19b93c --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/data/investment-portfolio.json b/database/data/investment-portfolio.json new file mode 100644 index 00000000..97dc1013 --- /dev/null +++ b/database/data/investment-portfolio.json @@ -0,0 +1,295 @@ +{ + "source": "upgrade do portal de investimentos v4 programacao.docx", + "sections": ["4.3.4", "4.5.2", "6.1", "6.6"], + "notice": "Proposta documental; valores, mapas, prioridades e disponibilidade sujeitos a validacao institucional.", + "projects": [ + { + "code": "A01", + "name": "MozParks – 20 Parques Industriais", + "region": "Nacional", + "location": "Âmbito nacional: parques industriais distribuídos pelas províncias de Moçambique; localização específica de cada parque sujeita a validação institucional.", + "sector": "Indústria e comércio", + "promotion": "Campanha nacional para investidores industriais, PME, operadores logísticos e empresas âncora.", + "description": "Plataforma nacional de industrialização descentralizada para instalar empresas, desenvolver fornecedores locais e integrar cadeias de valor em todas as províncias.", + "facts": "Âmbito nacional; indústria e comércio; investimento âncora indicado de USD 100 milhões; horizonte 2026–2030; potencial superior a 200 mil empregos directos e indirectos, sujeito a validação oficial.", + "partners": "Fabricantes, operadores de parques, empresas âncora, fundos industriais, logística, energia, água, tecnologia e PME fornecedoras.", + "cta": "Solicitar o dossier nacional, seleccionar uma província ou manifestar interesse em instalar, operar, financiar ou fornecer serviços." + }, + { + "code": "A02", + "name": "Linha Ferroviária Norte–Sul", + "region": "Nacional", + "location": "Corredor ferroviário de âmbito nacional, ligando as regiões Norte, Centro e Sul; traçado e pontos exactos dependentes dos estudos técnicos.", + "sector": "Transportes e logística", + "promotion": "Promoção como projecto estruturante de integração territorial, logística e PPP.", + "description": "Ligação ferroviária contínua entre Norte, Centro e Sul para reduzir custos logísticos, integrar mercados e reforçar Moçambique como plataforma regional.", + "facts": "Extensão estimada de 3 808 km; custo ainda dependente de estudos; horizonte 2026–2030; projecto estruturante de transportes e logística.", + "partners": "Operadores ferroviários, fundos de infra-estrutura, construtoras, fornecedores de material circulante, portos, mineração, agricultura e PPP.", + "cta": "Registar interesse em estudos, estruturação, financiamento, construção, operação ou fornecimento tecnológico." + }, + { + "code": "A03", + "name": "Programa de Desenvolvimento Integrado do Corredor de Nacala", + "region": "Norte", + "location": "Corredor de Nacala: Nampula, Cabo Delgado, Niassa, Tete e Norte da Zambézia, incluindo Nacala, Nampula, Cuamba, Palma e áreas ligadas ao corredor.", + "sector": "Logística, indústria, energia e agricultura", + "promotion": "Campanhas por corredor económico, exportação, parques industriais, energia, água e formação.", + "description": "Programa integrado que combina porto, ferrovia, indústria, agricultura, energia, água e serviços para transformar o Norte num corredor produtivo e exportador.", + "facts": "Nampula, Cabo Delgado, Niassa, Tete e Norte da Zambézia; custo global indicado de USD 2,43 mil milhões; cerca de 48 projectos complementares identificados.", + "partners": "Logística, portos, ferrovias, parques industriais, energia, água, agronegócio, formação, comércio regional e parceiros multilaterais.", + "cta": "Escolher um subprojecto, solicitar informação técnica ou propor financiamento, operação, construção ou parceria." + }, + { + "code": "A04", + "name": "Crusse Jamali – Zona Económica Especial de Turismo", + "region": "Norte", + "location": "Crusse Jamali, zona de Nacala, província de Nampula.", + "sector": "Turismo e serviços", + "promotion": "Promoção orientada a turismo sustentável, resorts, marinas, hotelaria e operadores internacionais.", + "description": "Destino turístico integrado de alta gama na costa de Nampula, articulando resorts, marina, residência, conectividade e serviços sustentáveis.", + "facts": "Nacala, Nampula; projecto âncora privado indicado em USD 99,2 milhões; rede complementar de porto, aeroporto, estrada, energia, água, formação e telecomunicações.", + "partners": "Grupos hoteleiros, promotores imobiliários, operadores turísticos, aviação, marinas, fundos de turismo, utilities e tecnologia.", + "cta": "Solicitar o dossier turístico, agendar apresentação ou propor desenvolvimento, operação, financiamento ou serviço complementar." + }, + { + "code": "A05", + "name": "Linha de Transmissão Namialo–Metoro (400 kV)", + "region": "Norte", + "location": "Eixo Namialo–Metoro, entre as províncias de Nampula e Cabo Delgado, com ligações a Pemba e Palma.", + "sector": "Energia e infra-estrutura", + "promotion": "Promoção junto de investidores em energia, EPC, manutenção, equipamentos e indústria eléctrica.", + "description": "Infra-estrutura energética para estabilizar o fornecimento ao Norte e suportar indústria, gás, mineração, cidades e novos investimentos.", + "facts": "Nampula–Cabo Delgado; custo âncora indicado de USD 550 milhões; inclui transmissão, subestações e interligações; horizonte 2026–2030.", + "partners": "Produtores e transmissores de energia, EPC, fabricantes de equipamento, fundos climáticos, manutenção, automação e formação técnica.", + "cta": "Manifestar interesse em financiamento, EPC, equipamentos, operação, manutenção ou componentes complementares." + }, + { + "code": "A06", + "name": "Cadeia de Valor do Arroz por Projecto de Processamento", + "region": "Centro", + "location": "Províncias de Tete, Manica, Zambézia e Sofala, articuladas pelos corredores da Beira e de Nacala e pelas bacias de irrigação identificadas.", + "sector": "Agro-indústria e agricultura", + "promotion": "Captação de investidores em processamento, irrigação, logística agrícola e substituição de importações.", + "description": "Programa de produção irrigada, processamento e logística para reduzir importações, aumentar produtividade e criar emprego rural no Centro.", + "facts": "Tete, Manica, Zambézia e Sofala; projecto âncora privado indicado em USD 36,71 milhões; complementares de irrigação, ferrovia, estrada, porto, energia e água.", + "partners": "Produtores, processadores, armazenagem, irrigação, maquinaria, logística, retalho alimentar, bancos e fundos de impacto.", + "cta": "Solicitar oportunidades por elo da cadeia ou propor capital, tecnologia, compra de produção, processamento ou logística." + }, + { + "code": "A07", + "name": "Parque Têxtil Integrado", + "region": "Centro", + "location": "Região Centro, com incidência principal em Sofala e articulação produtiva com Manica e Tete, junto ao Corredor da Beira.", + "sector": "Indústria transformadora", + "promotion": "Promoção para investidores têxteis, algodão, confecção, certificação, exportação e emprego industrial.", + "description": "Reindustrialização da fileira do algodão, da produção à fiação, tecelagem, confecção, certificação e exportação.", + "facts": "Zona Centro, com foco em Sofala, Manica e Tete; investimento âncora indicado em USD 42 milhões; integração com Porto e Corredor da Beira.", + "partners": "Fabricantes têxteis, marcas, fundos industriais, algodoeiras, equipamentos, logística, certificação, formação e compradores internacionais.", + "cta": "Manifestar interesse em instalar unidade, participar na cadeia, financiar, fornecer equipamento ou estabelecer contrato de compra." + }, + { + "code": "A08", + "name": "Zona Económica Especial de Mocuba", + "region": "Centro", + "location": "Mocuba e Munhamade, província da Zambézia, com ligações a Quelimane, Nacala, Caia e Milange.", + "sector": "Indústria, energia, logística e agro-indústria", + "promotion": "Campanha para instalação industrial, agro-processamento, logística, energia e serviços de suporte.", + "description": "Ecossistema industrial e agro-logístico para transformar a Zambézia num pólo de processamento, inovação, energia e exportação.", + "facts": "Mocuba e Munhamade; projecto âncora indicado em USD 37,4 milhões; complementares de porto, ferrovia, estrada, energia, água, formação e plataforma digital.", + "partners": "Agro-indústria, silvicultura, pecuária, logística, parques industriais, utilities, tecnologia, certificação e fundos regionais.", + "cta": "Seleccionar lote, cadeia de valor ou activo complementar e solicitar reunião com a entidade promotora." + }, + { + "code": "A09", + "name": "Construção da Cidadela Parlamentar de Mocuba", + "region": "Centro", + "location": "Cidade de Mocuba, província da Zambézia.", + "sector": "Infra-estruturas institucionais e urbanas", + "promotion": "Promoção voltada a construção, imobiliário, TIC, segurança, serviços urbanos e hotelaria de apoio.", + "description": "Complexo institucional e urbano destinado a reforçar a descentralização, serviços públicos, actividade económica e desenvolvimento urbano da Zambézia.", + "facts": "Mocuba; investimento âncora indicado em USD 120 milhões; componentes residenciais, administrativas, viárias, hoteleiras, segurança e TIC.", + "partners": "Construção, imobiliário, gestão de instalações, hotelaria, segurança, TIC, energia eficiente e serviços urbanos.", + "cta": "Registar capacidade e interesse em construção, fornecimento, tecnologia, operação de serviços ou parceria autorizada." + }, + { + "code": "A10", + "name": "Mphanda Nkuwa HPP – Hidroeléctrica", + "region": "Centro", + "location": "Rio Zambeze, província de Tete, a jusante da Hidroeléctrica de Cahora Bassa; área exacta sujeita à documentação oficial do projecto.", + "sector": "Energia", + "promotion": "Promoção junto de financiadores, investidores energéticos, equipamentos, transmissão e compradores de energia.", + "description": "Central hidroeléctrica de grande escala para ampliar energia limpa, reforçar a industrialização e consolidar exportações eléctricas regionais.", + "facts": "Tete, rio Zambeze; 1 500 MW; custo âncora indicado de USD 4,5 mil milhões; associado a transmissão, expansão de rede e electrificação.", + "partners": "Investidores energéticos, financiadores, EPC, turbinas, transmissão, compradores de energia, fundos climáticos e seguradoras.", + "cta": "Solicitar informação pública validada ou manifestar interesse em financiamento, fornecimento, construção ou serviços especializados." + }, + { + "code": "A11", + "name": "Corredor Ferroviário e Portuário de Macuse–Moatize", + "region": "Centro", + "location": "Corredor entre Moatize, na província de Tete, e Macuse, no distrito de Nicoadala, província da Zambézia, incluindo Mutarara, Mopeia e Quelimane.", + "sector": "Ferrovia, porto e logística", + "promotion": "Captação de parceiros para ferrovia, porto, terminais, mineração, carga geral e serviços logísticos.", + "description": "Corredor ferroviário e porto de águas profundas para ligar Tete à costa, diversificar rotas e aumentar a capacidade exportadora.", + "facts": "Tete–Zambézia; custo âncora indicado de USD 1,524 mil milhões; inclui porto, ferrovia, terminais, controlo, oficinas e mitigação social e ambiental.", + "partners": "Portos, ferrovias, mineração, carga geral, combustíveis, fundos de infra-estrutura, engenharia, tecnologia e operadores logísticos.", + "cta": "Manifestar interesse em estruturação, financiamento, construção, operação, carga contratual ou serviços complementares." + }, + { + "code": "A12", + "name": "Programa de Conectividade e Resiliência Costeira (Compacto II)", + "region": "Centro", + "location": "Província da Zambézia, com incidência em zonas rurais, no rio Licungo, na Circular de Mocuba e em comunidades e ecossistemas costeiros.", + "sector": "Infra-estrutura, agricultura, ambiente e clima", + "promotion": "Promoção para parceiros de conectividade rural, agricultura, resiliência climática e economia azul.", + "description": "Programa integrado para melhorar conectividade rural, investimento agrícola e resiliência de comunidades e ecossistemas costeiros da Zambézia.", + "facts": "Zambézia; Compacto II com componentes de transportes rurais, reforma e investimento agrícola e meios de vida costeiros; valores sujeitos a harmonização oficial.", + "partners": "Fundos climáticos, cooperação, agricultura, estradas, economia azul, conservação, tecnologia de monitoria e fundos de impacto.", + "cta": "Solicitar oportunidades de parceria, assistência técnica, co-financiamento, fornecimento ou inovação climática." + }, + { + "code": "A13", + "name": "Vilanculos – Região Integrada de Turismo", + "region": "Sul", + "location": "Corredor turístico Vilanculos–Inhassoro–Bazaruto, província de Inhambane, incluindo áreas costeiras e ilhas associadas.", + "sector": "Turismo e serviços", + "promotion": "Campanha para turismo costeiro, hotelaria, marina, aviação, formação e serviços digitais turísticos.", + "description": "Destino turístico costeiro integrado que combina hotelaria, acessibilidade, energia, água, formação, economia azul e serviços digitais.", + "facts": "Vilanculos–Inhassoro–Bazaruto; projecto âncora indicado em USD 20 milhões; 12 projectos complementares; horizonte 2026–2030.", + "partners": "Hotelaria, resorts, aviação, marinas, operadores turísticos, fundos imobiliários, utilities, formação e tecnologia.", + "cta": "Solicitar dossier, agendar apresentação ou propor investimento em alojamento, mobilidade, serviços, infra-estrutura ou formação." + }, + { + "code": "A14", + "name": "Zona Económica Especial de Agronegócio do Limpopo", + "region": "Sul", + "location": "Vale do Limpopo, província de Gaza: Chókwè, Xai-Xai, Guijá, Chongoene, Chibuto, Limpopo e Bilene.", + "sector": "Agro-indústria, irrigação e logística", + "promotion": "Promoção para investidores em produção agrícola, processamento, armazenagem, crédito e equipamentos.", + "description": "Ecossistema agro-industrial integrado para ligar irrigação, produção, processamento, armazenamento, energia, crédito e mercados.", + "facts": "Gaza; Chókwè, Xai-Xai, Guijá, Chongoene, Chibuto, Limpopo e Bilene; custo âncora indicado de USD 354 milhões.", + "partners": "Produtores, processadores, irrigação, armazenagem, logística, energia, maquinaria, bancos, fundos agrícolas e compradores.", + "cta": "Seleccionar componente, cultura ou serviço e manifestar interesse em capital, operação, fornecimento, crédito ou compra." + }, + { + "code": "A15", + "name": "Regadio na Bacia Hidrográfica do Rio Maputo", + "region": "Sul", + "location": "Bacia Hidrográfica do Rio Maputo, província de Maputo, com incidência nos distritos de Boane, Moamba e Namaacha e ligação às bacias do Maputo e Incomáti.", + "sector": "Agricultura irrigada, água e energia", + "promotion": "Captação de operadores agrícolas, tecnologia de irrigação, energia, infra-estrutura hídrica e gestão digital.", + "description": "Programa hídrico e agrícola para aumentar segurança de água, irrigação moderna, produtividade, processamento e resiliência climática.", + "facts": "Boane, Moamba e Namaacha; custo âncora indicado de USD 480 milhões; barragens, canais, drenagem, energia, estradas, formação e SCADA.", + "partners": "Operadores agrícolas, irrigação, engenharia hídrica, energia, tecnologia, fundos climáticos, logística, processamento e formação.", + "cta": "Propor financiamento, tecnologia, operação agrícola, fornecimento, assistência técnica ou parceria num componente validado." + } + ], + "clusters": [ + { + "code": "CLU-01", + "name": "Agro-indústria e segurança alimentar", + "anchors": "A06, A14 e A15; arroz, regadio, processamento, armazenamento, logística e comercialização.", + "gaps": "Irrigação, sementes, mecanização, frio, silos, embalagem, certificação, financiamento e compradores.", + "priority": "91 — MVP completo." + }, + { + "code": "CLU-02", + "name": "Energia e industrialização", + "anchors": "A01, A05, A08 e A10; parques, transmissão, geração e grandes consumidores.", + "gaps": "Subestações, geração complementar, manutenção, equipamentos, utilities, competências e contratos de compra.", + "priority": "89 — MVP completo." + }, + { + "code": "CLU-03", + "name": "Logística, corredores e exportação", + "anchors": "A02, A03 e A11; Nacala, Macuse–Moatize, Norte–Sul, portos, terminais e armazenagem.", + "gaps": "Última milha, intermodalidade, terminais, plataformas logísticas, digitalização, fronteiras e carga contratual.", + "priority": "88 — MVP completo." + }, + { + "code": "CLU-04", + "name": "Turismo costeiro e economia azul", + "anchors": "A04 e A13; Crusse Jamali, Vilanculos, Bazaruto, hotelaria, marinas e aviação.", + "gaps": "Alojamento, formação, mobilidade, água, energia, serviços digitais, conservação e experiências locais.", + "priority": "77 — Fase 2." + }, + { + "code": "CLU-05", + "name": "Têxtil, vestuário e algodão", + "anchors": "A07; algodão, fiação, tecelagem, confecção, marcas e exportação.", + "gaps": "Qualidade, equipamento, formação, certificação, logística, compradores, design e marcas.", + "priority": "74 — Fase 2." + }, + { + "code": "CLU-06", + "name": "Mineração e transformação mineral", + "anchors": "Exploração, processamento, laboratórios, certificação, logística, equipamentos e reabilitação.", + "gaps": "Conhecimento geológico, processamento local, energia, financiamento, tecnologia, competências, origem responsável e mercado.", + "priority": "73 — MVP limitado; diligência avançada na Fase 2." + }, + { + "code": "CLU-07", + "name": "Tecnologia e serviços digitais", + "anchors": "Fintech, govtech, centros de dados, incubação, formação e serviços empresariais.", + "gaps": "Capital de risco, competências, conectividade, investigação, clientes, propriedade intelectual e internacionalização.", + "priority": "72 — transversal no MVP; expansão na Fase 2." + }, + { + "code": "CLU-08", + "name": "Financiamento do investimento", + "anchors": "Bancos, DFI, fundos climáticos, garantias, venture capital, crédito sectorial e assistência técnica.", + "gaps": "Colateral, preparação de projectos, planos de negócio, instrumentos por fase, garantias e blended finance.", + "priority": "Habilitador transversal do MVP." + } + ], + "corridors": [ + { + "code": "COR-01", + "name": "Corredor de Nacala e Norte", + "anchors": "A03 Corredor de Nacala; A04 Crusse Jamali; A05 Namialo–Metoro; componentes de A01.", + "clusters": "Logística, energia, agro-indústria, mineração, turismo e indústria.", + "focus": "Terminais, armazenagem, processamento, parques, água, energia, formação, fornecedores e conectividade.", + "priority": "MVP — muito alta." + }, + { + "code": "COR-02", + "name": "Corredor da Beira e Centro", + "anchors": "A06 Cadeia do Arroz; A07 Parque Têxtil; componentes ferroviários, portuários e industriais.", + "clusters": "Agro-indústria, têxtil, logística, energia e serviços.", + "focus": "Regadio, silos, frio, processamento, fiação, logística, certificação, formação e financiamento.", + "priority": "MVP — muito alta." + }, + { + "code": "COR-03", + "name": "Corredor Zambeze–Macuse", + "anchors": "A08 ZEE de Mocuba; A09 Cidadela de Mocuba; A10 Mphanda Nkuwa; A11 Macuse–Moatize; A12 Resiliência Costeira.", + "clusters": "Energia, logística, mineração, agro-indústria, serviços urbanos, clima e indústria.", + "focus": "Transmissão, porto, terminais, parques, água, serviços urbanos, mitigação social e ambiental e fornecedores.", + "priority": "MVP — muito alta." + }, + { + "code": "COR-04", + "name": "Corredor Maputo–Limpopo", + "anchors": "A14 ZEE de Agronegócio do Limpopo; A15 Regadio da Bacia do Rio Maputo; componentes de A01.", + "clusters": "Agro-indústria, água, logística, indústria, comércio e serviços.", + "focus": "Irrigação, processamento, armazenagem, energia, embalagem, transporte, mercados e serviços financeiros.", + "priority": "MVP — alta." + }, + { + "code": "COR-05", + "name": "Corredor Turístico Inhambane–Bazaruto", + "anchors": "A13 Região Integrada de Turismo de Vilanculos.", + "clusters": "Turismo, economia azul, aviação, serviços, conservação e tecnologia.", + "focus": "Alojamento, marinas, mobilidade, formação, água, energia, experiências e serviços digitais.", + "priority": "Fase 2 — alta." + }, + { + "code": "COR-06", + "name": "Corredor Industrial Nacional Norte–Sul", + "anchors": "A01 MozParks; A02 Linha Ferroviária Norte–Sul.", + "clusters": "Indústria, logística, energia, tecnologia e cadeias provinciais.", + "focus": "Parques, ramais, utilities, centros logísticos, fornecedores, competências e digitalização.", + "priority": "Fase 2 — estruturante." + } + ] +} diff --git a/database/data/investment-reference-options.json b/database/data/investment-reference-options.json new file mode 100644 index 00000000..f6096843 --- /dev/null +++ b/database/data/investment-reference-options.json @@ -0,0 +1,16 @@ +{ + "need_category": {"labour_required":"Mão de obra necessária","labour_available":"Mão de obra disponível","infrastructure_existing":"Infraestruturas existentes","infrastructure_required":"Infraestruturas necessárias","education":"Educação e formação","support":"Apoios"}, + "infrastructure": {"energy":"Energia","water":"Água","sanitation":"Saneamento","telecom":"Telecomunicações","internet":"Internet","road":"Estrada","rail":"Ferrovia","port":"Porto","airport":"Aeroporto","storage":"Armazenagem","cold_chain":"Cadeia de frio","housing":"Habitação","health":"Saúde","education":"Educação","security":"Segurança","industrial":"Instalações industriais","office":"Escritórios","land":"Terrenos","logistics":"Serviços logísticos"}, + "education": {"preschool":"Educação pré-escolar","primary":"Ensino primário","secondary":"Ensino secundário","vocational":"Formação técnico-profissional","higher":"Ensino superior","research":"Investigação","innovation":"Centros de inovação","incubation":"Incubadoras","laboratory":"Laboratórios","edtech":"Tecnologia educativa","digital":"Plataformas digitais","connectivity":"Conectividade","buildings":"Infraestruturas escolares","equipment":"Equipamentos","library":"Bibliotecas","residence":"Alojamento","meals":"Alimentação escolar","transport":"Transporte","content":"Produção de conteúdos","teachers":"Formação de professores","skills":"Desenvolvimento de competências","continuing":"Formação contínua"}, + "labour": {"technical":"Pessoal técnico","professional":"Profissionais especializados","operational":"Pessoal operacional","management":"Gestão","training":"Formadores"}, + "qualification": {"primary":"Ensino primário","secondary":"Ensino secundário","technical":"Qualificação técnica","higher":"Ensino superior","postgraduate":"Pós-graduação"}, + "support": {"licence":"Licenças","incentives":"Incentivos","finance":"Financiamento","land":"Terra","logistics":"Logística","partners":"Parceiros","suppliers":"Fornecedores","recruitment":"Contratação","imports":"Importação","exports":"Exportação","installation":"Instalação","utilities":"Ligação a serviços públicos"}, + "priority": {"low":"Baixa","normal":"Normal","high":"Alta"}, + "unit": {"people":"Pessoas","units":"Unidades","mw":"MW","m3_day":"m³/dia","km":"km","hectares":"Hectares"}, + "participation": {"invest":"Investir","finance":"Financiar","supply":"Fornecer","operate":"Operar","partner":"Estabelecer parceria"}, + "partnership": {"direct":"Investimento directo","joint_venture":"Joint venture","ppp":"Parceria público-privada","supply":"Fornecimento","technical":"Cooperação técnica"}, + "investor_profile": {"individual":"Individual","company":"Empresa","fund":"Fundo","public":"Entidade pública"}, + "impact": {"employment":"Emprego","training":"Competências","local_supply":"Fornecimento local","environment":"Ambiente","exports":"Exportações"}, + "subsector": {}, + "need_status": {"declared":"Declarada","available":"Disponível segundo declaração","required":"Necessária"} +} diff --git a/database/factories/InvestorFactory.php b/database/factories/InvestorFactory.php new file mode 100644 index 00000000..1fdd88f3 --- /dev/null +++ b/database/factories/InvestorFactory.php @@ -0,0 +1,25 @@ + */ +class InvestorFactory extends Factory +{ + protected $model = Investor::class; + + public function definition(): array + { + return [ + 'source_system' => 'example', + 'external_id' => (string) Str::uuid(), + 'data_classification' => 'internal', + 'legal_name' => 'Investidor fictício de teste', + 'investor_type' => 'institutional', + 'status' => 'prospect', + ]; + } +} diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php new file mode 100644 index 00000000..88220e48 --- /dev/null +++ b/database/factories/ProjectFactory.php @@ -0,0 +1,27 @@ + */ +class ProjectFactory extends Factory +{ + protected $model = Project::class; + + public function definition(): array + { + return [ + 'source_system' => 'example', + 'external_id' => (string) Str::uuid(), + 'data_classification' => 'internal', + 'code' => 'EXAMPLE-'.Str::uuid(), + 'name' => 'Projecto fictício de teste', + 'status' => 'draft', + 'budget_amount' => '1000.00', + 'budget_currency' => 'MZN', + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 00000000..92dd0e64 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,64 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + /* @chisel-2fa */ + 'two_factor_secret' => null, + 'two_factor_recovery_codes' => null, + 'two_factor_confirmed_at' => null, + /* @end-chisel-2fa */ + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } + + /** + * Indicate that the model has two-factor authentication configured. + */ + public function withTwoFactor(): static + { + /* @chisel-2fa */ + return $this->state(fn (array $attributes) => [ + 'two_factor_secret' => encrypt('secret'), + 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1'])), + 'two_factor_confirmed_at' => now(), + ]); + /* @end-chisel-2fa */ + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 00000000..05fb5d9e --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 00000000..06dc7a5e --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->bigInteger('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->bigInteger('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 00000000..edac6fee --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,59 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedSmallInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('connection'); + $table->string('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2024_01_01_000000_create_passkeys_table.php b/database/migrations/2024_01_01_000000_create_passkeys_table.php new file mode 100644 index 00000000..cb884c39 --- /dev/null +++ b/database/migrations/2024_01_01_000000_create_passkeys_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('credential_id')->unique(); + $table->json('credential'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('passkeys'); + } +}; diff --git a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php new file mode 100644 index 00000000..187d974d --- /dev/null +++ b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php @@ -0,0 +1,34 @@ +text('two_factor_secret')->after('password')->nullable(); + $table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable(); + $table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'two_factor_secret', + 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_01_01_000001_create_sectors_table.php b/database/migrations/2026_01_01_000001_create_sectors_table.php new file mode 100644 index 00000000..e2849e72 --- /dev/null +++ b/database/migrations/2026_01_01_000001_create_sectors_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name', 150); + $table->string('slug', 180)->unique(); + $table->string('short_description', 255)->nullable(); + $table->text('description')->nullable(); + $table->string('icon', 100)->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('slug'); + $table->index('is_active'); + }); + } + + public function down(): void + { + Schema::dropIfExists('sectors'); + } +}; diff --git a/database/migrations/2026_01_01_000002_create_opportunities_table.php b/database/migrations/2026_01_01_000002_create_opportunities_table.php new file mode 100644 index 00000000..7653b8df --- /dev/null +++ b/database/migrations/2026_01_01_000002_create_opportunities_table.php @@ -0,0 +1,62 @@ +id(); + + $table->foreignId('sector_id') + ->constrained('sectors') + ->cascadeOnUpdate() + ->restrictOnDelete(); + + $table->string('reference', 80)->unique(); + $table->string('title', 200); + $table->string('slug', 220)->unique(); + $table->string('summary', 500)->nullable(); + $table->longText('description')->nullable(); + + $table->string('province', 100)->nullable(); + $table->string('district', 100)->nullable(); + + $table->string('promoter_name', 200)->nullable(); + $table->string('promoter_type', 100)->nullable(); + + $table->unsignedTinyInteger('maturity_level')->nullable(); + $table->string('maturity_label', 120)->nullable(); + + $table->decimal('estimated_investment_amount', 18, 2)->nullable(); + $table->string('estimated_investment_currency', 10)->default('USD'); + $table->string('financing_modality', 120)->nullable(); + $table->text('expected_impact')->nullable(); + + $table->string('status', 50)->default('draft'); + $table->boolean('is_featured')->default(false); + + $table->timestamp('published_at')->nullable(); + $table->timestamp('last_validated_at')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + + $table->index('sector_id'); + $table->index('reference'); + $table->index('slug'); + $table->index('status'); + $table->index('is_featured'); + $table->index('province'); + $table->index('maturity_level'); + }); + } + + public function down(): void + { + Schema::dropIfExists('opportunities'); + } +}; diff --git a/database/migrations/2026_01_01_000003_create_interest_requests_table.php b/database/migrations/2026_01_01_000003_create_interest_requests_table.php new file mode 100644 index 00000000..a5744a97 --- /dev/null +++ b/database/migrations/2026_01_01_000003_create_interest_requests_table.php @@ -0,0 +1,61 @@ +id(); + + $table->foreignId('opportunity_id') + ->nullable() + ->constrained('opportunities') + ->cascadeOnUpdate() + ->nullOnDelete(); + + $table->string('reference', 80)->unique(); + + $table->string('full_name', 150); + $table->string('email', 150); + $table->string('phone', 30)->nullable(); + + $table->string('investor_type', 80); + $table->string('country', 100); + $table->string('sector_interest', 150); + $table->string('opportunity_reference', 80)->nullable(); + $table->string('investment_range', 80)->nullable(); + + $table->text('message'); + + $table->boolean('privacy_consent')->default(false); + $table->timestamp('consented_at')->nullable(); + + $table->string('status', 50)->default('received'); + + $table->string('source_page', 255)->nullable(); + $table->ipAddress('ip_address')->nullable(); + $table->text('user_agent')->nullable(); + $table->json('metadata')->nullable(); + + $table->timestamp('reviewed_at')->nullable(); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + + $table->timestamps(); + $table->softDeletes(); + + $table->index('reference'); + $table->index('email'); + $table->index('status'); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('interest_requests'); + } +}; diff --git a/database/migrations/2026_09_24_000100_investmoz_extensions_functions.php b/database/migrations/2026_09_24_000100_investmoz_extensions_functions.php new file mode 100644 index 00000000..fb7b6472 --- /dev/null +++ b/database/migrations/2026_09_24_000100_investmoz_extensions_functions.php @@ -0,0 +1,78 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE EXTENSION IF NOT EXISTS postgis; +SQL); + + DB::statement(<<<'SQL' +CREATE EXTENSION IF NOT EXISTS pgcrypto; +SQL); + + DB::statement(<<<'SQL' +CREATE OR REPLACE FUNCTION public.investmoz_set_updated_at() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$; +SQL); + + DB::statement(<<<'SQL' +CREATE OR REPLACE FUNCTION public.investmoz_reject_audit_change() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$ +BEGIN + RAISE EXCEPTION 'InvestMoz audit_events is append-only' USING ERRCODE = '42501'; +END; +$$; +SQL); + + DB::statement(<<<'SQL' +CREATE OR REPLACE FUNCTION public.investmoz_protect_event_envelope() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'InvestMoz event deletion requires a reviewed retention migration' USING ERRCODE = '42501'; + END IF; + IF (to_jsonb(NEW) - ARRAY['status','updated_at','published_at','processed_at','available_at']) + IS DISTINCT FROM + (to_jsonb(OLD) - ARRAY['status','updated_at','published_at','processed_at','available_at']) THEN + RAISE EXCEPTION 'InvestMoz event envelope is immutable' USING ERRCODE = '42501'; + END IF; + RETURN NEW; +END; +$$; +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +DROP FUNCTION IF EXISTS public.investmoz_protect_event_envelope() RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP FUNCTION IF EXISTS public.investmoz_reject_audit_change() RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP FUNCTION IF EXISTS public.investmoz_set_updated_at() RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_24_001000_investmoz_identity.php b/database/migrations/2026_09_24_001000_investmoz_identity.php new file mode 100644 index 00000000..dc41900d --- /dev/null +++ b/database/migrations/2026_09_24_001000_investmoz_identity.php @@ -0,0 +1,159 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.organizations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + name text NOT NULL, + organization_type text NOT NULL, + CONSTRAINT imz_organizations_pk PRIMARY KEY (id), + CONSTRAINT imz_organizations_check_01 CHECK (version_no > 0), + CONSTRAINT imz_organizations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_organizations_check_03 CHECK (organization_type IN ('public','private','ngo','education')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.memberships ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + user_id bigint NOT NULL CONSTRAINT imz_memberships_user_id_fk REFERENCES public.users(id) ON DELETE CASCADE ON UPDATE CASCADE, + organization_id uuid NOT NULL CONSTRAINT imz_memberships_organization_id_fk REFERENCES public.organizations(id) ON DELETE CASCADE ON UPDATE CASCADE, + role text NOT NULL, + CONSTRAINT imz_memberships_pk PRIMARY KEY (id), + CONSTRAINT imz_memberships_check_01 CHECK (version_no > 0), + CONSTRAINT imz_memberships_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_memberships_check_03 CHECK (role IN ('admin','manager','officer','viewer')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.consents ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + user_id bigint CONSTRAINT imz_consents_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + subject_ref text NOT NULL, + purpose text NOT NULL, + status text NOT NULL, + granted_at timestamptz, + withdrawn_at timestamptz, + CONSTRAINT imz_consents_pk PRIMARY KEY (id), + CONSTRAINT imz_consents_check_01 CHECK (version_no > 0), + CONSTRAINT imz_consents_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_consents_check_03 CHECK (status IN ('granted','withdrawn','expired')) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.consents DROP CONSTRAINT IF EXISTS imz_consents_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.consents DROP CONSTRAINT IF EXISTS imz_consents_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.consents DROP CONSTRAINT IF EXISTS imz_consents_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.consents DROP CONSTRAINT IF EXISTS imz_consents_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.consents DROP CONSTRAINT IF EXISTS imz_consents_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.consents RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.memberships DROP CONSTRAINT IF EXISTS imz_memberships_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.memberships DROP CONSTRAINT IF EXISTS imz_memberships_organization_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.memberships DROP CONSTRAINT IF EXISTS imz_memberships_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.memberships DROP CONSTRAINT IF EXISTS imz_memberships_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.memberships DROP CONSTRAINT IF EXISTS imz_memberships_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.memberships DROP CONSTRAINT IF EXISTS imz_memberships_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.memberships RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.organizations DROP CONSTRAINT IF EXISTS imz_organizations_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.organizations DROP CONSTRAINT IF EXISTS imz_organizations_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.organizations DROP CONSTRAINT IF EXISTS imz_organizations_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.organizations DROP CONSTRAINT IF EXISTS imz_organizations_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.organizations RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_24_002000_investmoz_content.php b/database/migrations/2026_09_24_002000_investmoz_content.php new file mode 100644 index 00000000..206c8e37 --- /dev/null +++ b/database/migrations/2026_09_24_002000_investmoz_content.php @@ -0,0 +1,70 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.documents ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + file_name text NOT NULL, + media_type text NOT NULL, + storage_key text NOT NULL, + sha256 char(64) NOT NULL, + status text NOT NULL, + CONSTRAINT imz_documents_pk PRIMARY KEY (id), + CONSTRAINT imz_documents_check_01 CHECK (version_no > 0), + CONSTRAINT imz_documents_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_documents_check_03 CHECK (status IN ('active','quarantined','deleted')), + CONSTRAINT imz_documents_check_04 CHECK (sha256 ~ '^[0-9a-f]{64}$') +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.documents DROP CONSTRAINT IF EXISTS imz_documents_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.documents DROP CONSTRAINT IF EXISTS imz_documents_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.documents DROP CONSTRAINT IF EXISTS imz_documents_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.documents DROP CONSTRAINT IF EXISTS imz_documents_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.documents DROP CONSTRAINT IF EXISTS imz_documents_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.documents RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_24_003000_investmoz_geography.php b/database/migrations/2026_09_24_003000_investmoz_geography.php new file mode 100644 index 00000000..4f11d37f --- /dev/null +++ b/database/migrations/2026_09_24_003000_investmoz_geography.php @@ -0,0 +1,144 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.geo_locations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + name text NOT NULL, + location_type text NOT NULL, + parent_id uuid CONSTRAINT imz_geo_locations_parent_id_fk REFERENCES public.geo_locations(id) ON DELETE RESTRICT ON UPDATE CASCADE, + code text NOT NULL, + CONSTRAINT imz_geo_locations_pk PRIMARY KEY (id), + CONSTRAINT imz_geo_locations_check_01 CHECK (version_no > 0), + CONSTRAINT imz_geo_locations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_geo_locations_check_03 CHECK (location_type IN ('country','province','district','municipality','site')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.geo_geometries ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + geo_location_id uuid NOT NULL CONSTRAINT imz_geo_geometries_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE CASCADE ON UPDATE CASCADE, + geometry_type text NOT NULL, + geom geometry(Geometry,4326) NOT NULL, + properties jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT imz_geo_geometries_pk PRIMARY KEY (id), + CONSTRAINT imz_geo_geometries_check_01 CHECK (version_no > 0), + CONSTRAINT imz_geo_geometries_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_geo_geometries_check_03 CHECK (geometry_type IN ('point','line','polygon','multipolygon')), + CONSTRAINT imz_geo_geometries_check_04 CHECK (ST_SRID(geom) = 4326), + CONSTRAINT imz_geo_geometries_check_05 CHECK (ST_NDims(geom) = 2), + CONSTRAINT imz_geo_geometries_check_06 CHECK (NOT ST_IsEmpty(geom)), + CONSTRAINT imz_geo_geometries_check_07 CHECK (ST_IsValid(geom)), + CONSTRAINT imz_geo_geometries_check_08 CHECK ((geometry_type = 'point' AND GeometryType(geom) = 'POINT') OR (geometry_type = 'line' AND GeometryType(geom) = 'LINESTRING') OR (geometry_type = 'polygon' AND GeometryType(geom) = 'POLYGON') OR (geometry_type = 'multipolygon' AND GeometryType(geom) = 'MULTIPOLYGON')), + CONSTRAINT imz_geo_geometries_check_09 CHECK (ST_XMin(Box3D(geom)) >= -180 AND ST_XMax(Box3D(geom)) <= 180 AND ST_YMin(Box3D(geom)) >= -90 AND ST_YMax(Box3D(geom)) <= 90) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_geo_location_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_05 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_06 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_07 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_08 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_09 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.geo_geometries RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_locations DROP CONSTRAINT IF EXISTS imz_geo_locations_parent_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_locations DROP CONSTRAINT IF EXISTS imz_geo_locations_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_locations DROP CONSTRAINT IF EXISTS imz_geo_locations_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_locations DROP CONSTRAINT IF EXISTS imz_geo_locations_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_locations DROP CONSTRAINT IF EXISTS imz_geo_locations_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.geo_locations RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_24_004000_investmoz_projects.php b/database/migrations/2026_09_24_004000_investmoz_projects.php new file mode 100644 index 00000000..e1ef74e4 --- /dev/null +++ b/database/migrations/2026_09_24_004000_investmoz_projects.php @@ -0,0 +1,138 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.projects ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + code text NOT NULL, + name text NOT NULL, + status text NOT NULL, + description text, + budget_amount numeric(20,2), + budget_currency char(3), + start_date date, + end_date date, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT imz_projects_pk PRIMARY KEY (id), + CONSTRAINT imz_projects_check_01 CHECK (version_no > 0), + CONSTRAINT imz_projects_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_projects_check_03 CHECK (status IN ('draft','planned','active','suspended','completed','cancelled')), + CONSTRAINT imz_projects_check_04 CHECK (budget_amount IS NULL OR budget_amount >= 0), + CONSTRAINT imz_projects_check_05 CHECK (end_date IS NULL OR start_date IS NULL OR end_date >= start_date), + CONSTRAINT imz_projects_check_06 CHECK ((budget_amount IS NULL) = (budget_currency IS NULL)), + CONSTRAINT imz_projects_check_07 CHECK (budget_currency IS NULL OR budget_currency ~ '^[A-Z]{3}$') +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.project_locations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + project_id uuid NOT NULL CONSTRAINT imz_project_locations_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE, + geo_location_id uuid NOT NULL CONSTRAINT imz_project_locations_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE RESTRICT ON UPDATE CASCADE, + role text NOT NULL, + CONSTRAINT imz_project_locations_pk PRIMARY KEY (id), + CONSTRAINT imz_project_locations_check_01 CHECK (version_no > 0), + CONSTRAINT imz_project_locations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_project_locations_check_03 CHECK (role IN ('primary','secondary','impact')) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_locations DROP CONSTRAINT IF EXISTS imz_project_locations_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_locations DROP CONSTRAINT IF EXISTS imz_project_locations_geo_location_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_locations DROP CONSTRAINT IF EXISTS imz_project_locations_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_locations DROP CONSTRAINT IF EXISTS imz_project_locations_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_locations DROP CONSTRAINT IF EXISTS imz_project_locations_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_locations DROP CONSTRAINT IF EXISTS imz_project_locations_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.project_locations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_check_05 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_check_06 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_check_07 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.projects RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_24_005000_investmoz_clusters.php b/database/migrations/2026_09_24_005000_investmoz_clusters.php new file mode 100644 index 00000000..2f5b2c75 --- /dev/null +++ b/database/migrations/2026_09_24_005000_investmoz_clusters.php @@ -0,0 +1,312 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.economic_clusters ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + code text NOT NULL, + name text NOT NULL, + sector text NOT NULL, + status text NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT imz_economic_clusters_pk PRIMARY KEY (id), + CONSTRAINT imz_economic_clusters_check_01 CHECK (version_no > 0), + CONSTRAINT imz_economic_clusters_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_economic_clusters_check_03 CHECK (status IN ('planned','active','inactive')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.value_chains ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + cluster_id uuid NOT NULL CONSTRAINT imz_value_chains_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE, + code text NOT NULL, + name text NOT NULL, + stage text NOT NULL, + CONSTRAINT imz_value_chains_pk PRIMARY KEY (id), + CONSTRAINT imz_value_chains_check_01 CHECK (version_no > 0), + CONSTRAINT imz_value_chains_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_value_chains_check_03 CHECK (stage IN ('input','production','processing','logistics','market')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.value_chain_links ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + from_value_chain_id uuid NOT NULL CONSTRAINT imz_value_chain_links_from_value_chain_id_fk REFERENCES public.value_chains(id) ON DELETE CASCADE ON UPDATE CASCADE, + to_value_chain_id uuid NOT NULL CONSTRAINT imz_value_chain_links_to_value_chain_id_fk REFERENCES public.value_chains(id) ON DELETE CASCADE ON UPDATE CASCADE, + link_type text NOT NULL, + CONSTRAINT imz_value_chain_links_pk PRIMARY KEY (id), + CONSTRAINT imz_value_chain_links_check_01 CHECK (version_no > 0), + CONSTRAINT imz_value_chain_links_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_value_chain_links_check_03 CHECK (link_type IN ('supplies','processes','distributes','supports')), + CONSTRAINT imz_value_chain_links_check_04 CHECK (from_value_chain_id <> to_value_chain_id) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.cluster_projects ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + cluster_id uuid NOT NULL CONSTRAINT imz_cluster_projects_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE, + project_id uuid NOT NULL CONSTRAINT imz_cluster_projects_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE, + role text NOT NULL, + CONSTRAINT imz_cluster_projects_pk PRIMARY KEY (id), + CONSTRAINT imz_cluster_projects_check_01 CHECK (version_no > 0), + CONSTRAINT imz_cluster_projects_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_cluster_projects_check_03 CHECK (role IN ('anchor','complementary','supporting')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.anchor_project_links ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + anchor_project_id uuid NOT NULL CONSTRAINT imz_anchor_project_links_anchor_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE, + cluster_id uuid NOT NULL CONSTRAINT imz_anchor_project_links_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE, + rationale text NOT NULL, + CONSTRAINT imz_anchor_project_links_pk PRIMARY KEY (id), + CONSTRAINT imz_anchor_project_links_check_01 CHECK (version_no > 0), + CONSTRAINT imz_anchor_project_links_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.complementary_project_links ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + anchor_project_id uuid NOT NULL CONSTRAINT imz_complementary_project_links_anchor_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE, + complementary_project_id uuid NOT NULL CONSTRAINT imz_complementary_project_links_complementary_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE, + relation_type text NOT NULL, + CONSTRAINT imz_complementary_project_links_pk PRIMARY KEY (id), + CONSTRAINT imz_complementary_project_links_check_01 CHECK (version_no > 0), + CONSTRAINT imz_complementary_project_links_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_complementary_project_links_check_03 CHECK (relation_type IN ('dependency','enabler','supplier','market')), + CONSTRAINT imz_complementary_project_links_check_04 CHECK (anchor_project_id<>complementary_project_id) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.complementary_project_links DROP CONSTRAINT IF EXISTS imz_complementary_project_links_anchor_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.complementary_project_links DROP CONSTRAINT IF EXISTS imz_complementary_project_links_complementary_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.complementary_project_links DROP CONSTRAINT IF EXISTS imz_complementary_project_links_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.complementary_project_links DROP CONSTRAINT IF EXISTS imz_complementary_project_links_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.complementary_project_links DROP CONSTRAINT IF EXISTS imz_complementary_project_links_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.complementary_project_links DROP CONSTRAINT IF EXISTS imz_complementary_project_links_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.complementary_project_links DROP CONSTRAINT IF EXISTS imz_complementary_project_links_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.complementary_project_links RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.anchor_project_links DROP CONSTRAINT IF EXISTS imz_anchor_project_links_anchor_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.anchor_project_links DROP CONSTRAINT IF EXISTS imz_anchor_project_links_cluster_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.anchor_project_links DROP CONSTRAINT IF EXISTS imz_anchor_project_links_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.anchor_project_links DROP CONSTRAINT IF EXISTS imz_anchor_project_links_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.anchor_project_links DROP CONSTRAINT IF EXISTS imz_anchor_project_links_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.anchor_project_links RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.cluster_projects DROP CONSTRAINT IF EXISTS imz_cluster_projects_cluster_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.cluster_projects DROP CONSTRAINT IF EXISTS imz_cluster_projects_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.cluster_projects DROP CONSTRAINT IF EXISTS imz_cluster_projects_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.cluster_projects DROP CONSTRAINT IF EXISTS imz_cluster_projects_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.cluster_projects DROP CONSTRAINT IF EXISTS imz_cluster_projects_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.cluster_projects DROP CONSTRAINT IF EXISTS imz_cluster_projects_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.cluster_projects RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chain_links DROP CONSTRAINT IF EXISTS imz_value_chain_links_from_value_chain_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chain_links DROP CONSTRAINT IF EXISTS imz_value_chain_links_to_value_chain_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chain_links DROP CONSTRAINT IF EXISTS imz_value_chain_links_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chain_links DROP CONSTRAINT IF EXISTS imz_value_chain_links_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chain_links DROP CONSTRAINT IF EXISTS imz_value_chain_links_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chain_links DROP CONSTRAINT IF EXISTS imz_value_chain_links_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chain_links DROP CONSTRAINT IF EXISTS imz_value_chain_links_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.value_chain_links RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chains DROP CONSTRAINT IF EXISTS imz_value_chains_cluster_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chains DROP CONSTRAINT IF EXISTS imz_value_chains_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chains DROP CONSTRAINT IF EXISTS imz_value_chains_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chains DROP CONSTRAINT IF EXISTS imz_value_chains_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chains DROP CONSTRAINT IF EXISTS imz_value_chains_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.value_chains RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.economic_clusters DROP CONSTRAINT IF EXISTS imz_economic_clusters_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.economic_clusters DROP CONSTRAINT IF EXISTS imz_economic_clusters_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.economic_clusters DROP CONSTRAINT IF EXISTS imz_economic_clusters_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.economic_clusters DROP CONSTRAINT IF EXISTS imz_economic_clusters_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.economic_clusters RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_24_006000_investmoz_corridors.php b/database/migrations/2026_09_24_006000_investmoz_corridors.php new file mode 100644 index 00000000..9dc79db8 --- /dev/null +++ b/database/migrations/2026_09_24_006000_investmoz_corridors.php @@ -0,0 +1,199 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.development_corridors ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + code text NOT NULL, + name text NOT NULL, + status text NOT NULL, + description text, + CONSTRAINT imz_development_corridors_pk PRIMARY KEY (id), + CONSTRAINT imz_development_corridors_check_01 CHECK (version_no > 0), + CONSTRAINT imz_development_corridors_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_development_corridors_check_03 CHECK (status IN ('proposed','active','closed')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.corridor_locations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + corridor_id uuid NOT NULL CONSTRAINT imz_corridor_locations_corridor_id_fk REFERENCES public.development_corridors(id) ON DELETE CASCADE ON UPDATE CASCADE, + geo_location_id uuid NOT NULL CONSTRAINT imz_corridor_locations_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE RESTRICT ON UPDATE CASCADE, + sequence_no integer NOT NULL, + CONSTRAINT imz_corridor_locations_pk PRIMARY KEY (id), + CONSTRAINT imz_corridor_locations_check_01 CHECK (version_no > 0), + CONSTRAINT imz_corridor_locations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_corridor_locations_check_03 CHECK (sequence_no>0) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.corridor_projects ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + corridor_id uuid NOT NULL CONSTRAINT imz_corridor_projects_corridor_id_fk REFERENCES public.development_corridors(id) ON DELETE CASCADE ON UPDATE CASCADE, + project_id uuid NOT NULL CONSTRAINT imz_corridor_projects_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT imz_corridor_projects_pk PRIMARY KEY (id), + CONSTRAINT imz_corridor_projects_check_01 CHECK (version_no > 0), + CONSTRAINT imz_corridor_projects_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.corridor_clusters ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + corridor_id uuid NOT NULL CONSTRAINT imz_corridor_clusters_corridor_id_fk REFERENCES public.development_corridors(id) ON DELETE CASCADE ON UPDATE CASCADE, + cluster_id uuid NOT NULL CONSTRAINT imz_corridor_clusters_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT imz_corridor_clusters_pk PRIMARY KEY (id), + CONSTRAINT imz_corridor_clusters_check_01 CHECK (version_no > 0), + CONSTRAINT imz_corridor_clusters_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_clusters DROP CONSTRAINT IF EXISTS imz_corridor_clusters_corridor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_clusters DROP CONSTRAINT IF EXISTS imz_corridor_clusters_cluster_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_clusters DROP CONSTRAINT IF EXISTS imz_corridor_clusters_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_clusters DROP CONSTRAINT IF EXISTS imz_corridor_clusters_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_clusters DROP CONSTRAINT IF EXISTS imz_corridor_clusters_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.corridor_clusters RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_projects DROP CONSTRAINT IF EXISTS imz_corridor_projects_corridor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_projects DROP CONSTRAINT IF EXISTS imz_corridor_projects_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_projects DROP CONSTRAINT IF EXISTS imz_corridor_projects_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_projects DROP CONSTRAINT IF EXISTS imz_corridor_projects_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_projects DROP CONSTRAINT IF EXISTS imz_corridor_projects_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.corridor_projects RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_locations DROP CONSTRAINT IF EXISTS imz_corridor_locations_corridor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_locations DROP CONSTRAINT IF EXISTS imz_corridor_locations_geo_location_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_locations DROP CONSTRAINT IF EXISTS imz_corridor_locations_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_locations DROP CONSTRAINT IF EXISTS imz_corridor_locations_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_locations DROP CONSTRAINT IF EXISTS imz_corridor_locations_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_locations DROP CONSTRAINT IF EXISTS imz_corridor_locations_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.corridor_locations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.development_corridors DROP CONSTRAINT IF EXISTS imz_development_corridors_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.development_corridors DROP CONSTRAINT IF EXISTS imz_development_corridors_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.development_corridors DROP CONSTRAINT IF EXISTS imz_development_corridors_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.development_corridors DROP CONSTRAINT IF EXISTS imz_development_corridors_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.development_corridors RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_24_007000_investmoz_crm.php b/database/migrations/2026_09_24_007000_investmoz_crm.php new file mode 100644 index 00000000..ee5adfe4 --- /dev/null +++ b/database/migrations/2026_09_24_007000_investmoz_crm.php @@ -0,0 +1,387 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.investors ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + organization_id uuid CONSTRAINT imz_investors_organization_id_fk REFERENCES public.organizations(id) ON DELETE SET NULL ON UPDATE CASCADE, + legal_name text NOT NULL, + investor_type text NOT NULL, + status text NOT NULL, + CONSTRAINT imz_investors_pk PRIMARY KEY (id), + CONSTRAINT imz_investors_check_01 CHECK (version_no > 0), + CONSTRAINT imz_investors_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_investors_check_03 CHECK (investor_type IN ('domestic','foreign','institutional','individual')), + CONSTRAINT imz_investors_check_04 CHECK (status IN ('prospect','active','inactive')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.contacts ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + full_name text NOT NULL, + email text, + phone text, + CONSTRAINT imz_contacts_pk PRIMARY KEY (id), + CONSTRAINT imz_contacts_check_01 CHECK (version_no > 0), + CONSTRAINT imz_contacts_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.investor_contacts ( + investor_id uuid NOT NULL CONSTRAINT imz_investor_contacts_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE, + contact_id uuid NOT NULL CONSTRAINT imz_investor_contacts_contact_id_fk REFERENCES public.contacts(id) ON DELETE CASCADE ON UPDATE CASCADE, + is_primary boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_investor_contacts_pk PRIMARY KEY (investor_id, contact_id) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.leads ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + investor_id uuid CONSTRAINT imz_leads_investor_id_fk REFERENCES public.investors(id) ON DELETE SET NULL ON UPDATE CASCADE, + owner_user_id bigint CONSTRAINT imz_leads_owner_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + title text NOT NULL, + status text NOT NULL, + estimated_amount numeric(20,2), + currency char(3), + CONSTRAINT imz_leads_pk PRIMARY KEY (id), + CONSTRAINT imz_leads_check_01 CHECK (version_no > 0), + CONSTRAINT imz_leads_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_leads_check_03 CHECK (status IN ('new','qualified','disqualified','converted')), + CONSTRAINT imz_leads_check_04 CHECK ((estimated_amount IS NULL)=(currency IS NULL)), + CONSTRAINT imz_leads_check_05 CHECK (estimated_amount IS NULL OR estimated_amount >= 0), + CONSTRAINT imz_leads_check_06 CHECK (currency IS NULL OR currency ~ '^[A-Z]{3}$') +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.crm_opportunities ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + lead_id uuid CONSTRAINT imz_crm_opportunities_lead_id_fk REFERENCES public.leads(id) ON DELETE SET NULL ON UPDATE CASCADE, + project_id uuid CONSTRAINT imz_crm_opportunities_project_id_fk REFERENCES public.projects(id) ON DELETE SET NULL ON UPDATE CASCADE, + owner_user_id bigint CONSTRAINT imz_crm_opportunities_owner_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + stage text NOT NULL, + amount numeric(20,2), + currency char(3), + probability numeric(5,2), + CONSTRAINT imz_crm_opportunities_pk PRIMARY KEY (id), + CONSTRAINT imz_crm_opportunities_check_01 CHECK (version_no > 0), + CONSTRAINT imz_crm_opportunities_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_crm_opportunities_check_03 CHECK (stage IN ('discovery','assessment','negotiation','approved','lost')), + CONSTRAINT imz_crm_opportunities_check_04 CHECK (probability BETWEEN 0 AND 100), + CONSTRAINT imz_crm_opportunities_check_05 CHECK ((amount IS NULL)=(currency IS NULL)), + CONSTRAINT imz_crm_opportunities_check_06 CHECK (amount IS NULL OR amount >= 0), + CONSTRAINT imz_crm_opportunities_check_07 CHECK (currency IS NULL OR currency ~ '^[A-Z]{3}$') +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.activities ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + investor_id uuid CONSTRAINT imz_activities_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE, + opportunity_id uuid CONSTRAINT imz_activities_opportunity_id_fk REFERENCES public.crm_opportunities(id) ON DELETE SET NULL ON UPDATE CASCADE, + actor_user_id bigint CONSTRAINT imz_activities_actor_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + activity_type text NOT NULL, + occurred_at timestamptz NOT NULL, + summary text NOT NULL, + CONSTRAINT imz_activities_pk PRIMARY KEY (id), + CONSTRAINT imz_activities_check_01 CHECK (version_no > 0), + CONSTRAINT imz_activities_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_activities_check_03 CHECK (activity_type IN ('call','meeting','email','note')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.tasks ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + assignee_user_id bigint CONSTRAINT imz_tasks_assignee_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + opportunity_id uuid CONSTRAINT imz_tasks_opportunity_id_fk REFERENCES public.crm_opportunities(id) ON DELETE CASCADE ON UPDATE CASCADE, + title text NOT NULL, + status text NOT NULL, + due_at timestamptz, + CONSTRAINT imz_tasks_pk PRIMARY KEY (id), + CONSTRAINT imz_tasks_check_01 CHECK (version_no > 0), + CONSTRAINT imz_tasks_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_tasks_check_03 CHECK (status IN ('open','in_progress','done','cancelled')) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.tasks DROP CONSTRAINT IF EXISTS imz_tasks_assignee_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.tasks DROP CONSTRAINT IF EXISTS imz_tasks_opportunity_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.tasks DROP CONSTRAINT IF EXISTS imz_tasks_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.tasks DROP CONSTRAINT IF EXISTS imz_tasks_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.tasks DROP CONSTRAINT IF EXISTS imz_tasks_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.tasks DROP CONSTRAINT IF EXISTS imz_tasks_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.tasks RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.activities DROP CONSTRAINT IF EXISTS imz_activities_investor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.activities DROP CONSTRAINT IF EXISTS imz_activities_opportunity_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.activities DROP CONSTRAINT IF EXISTS imz_activities_actor_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.activities DROP CONSTRAINT IF EXISTS imz_activities_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.activities DROP CONSTRAINT IF EXISTS imz_activities_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.activities DROP CONSTRAINT IF EXISTS imz_activities_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.activities DROP CONSTRAINT IF EXISTS imz_activities_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.activities RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_lead_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_owner_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_check_05 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_check_06 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_check_07 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.crm_opportunities RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_investor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_owner_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_check_05 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_check_06 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.leads RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_contacts DROP CONSTRAINT IF EXISTS imz_investor_contacts_investor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_contacts DROP CONSTRAINT IF EXISTS imz_investor_contacts_contact_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_contacts DROP CONSTRAINT IF EXISTS imz_investor_contacts_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.investor_contacts RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.contacts DROP CONSTRAINT IF EXISTS imz_contacts_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.contacts DROP CONSTRAINT IF EXISTS imz_contacts_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.contacts DROP CONSTRAINT IF EXISTS imz_contacts_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.contacts RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investors DROP CONSTRAINT IF EXISTS imz_investors_organization_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investors DROP CONSTRAINT IF EXISTS imz_investors_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investors DROP CONSTRAINT IF EXISTS imz_investors_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investors DROP CONSTRAINT IF EXISTS imz_investors_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investors DROP CONSTRAINT IF EXISTS imz_investors_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investors DROP CONSTRAINT IF EXISTS imz_investors_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.investors RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php b/database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php new file mode 100644 index 00000000..ea26ed6d --- /dev/null +++ b/database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php @@ -0,0 +1,337 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.investor_journeys ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + investor_id uuid NOT NULL CONSTRAINT imz_investor_journeys_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE, + opportunity_id uuid CONSTRAINT imz_investor_journeys_opportunity_id_fk REFERENCES public.crm_opportunities(id) ON DELETE SET NULL ON UPDATE CASCADE, + status text NOT NULL, + started_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_investor_journeys_pk PRIMARY KEY (id), + CONSTRAINT imz_investor_journeys_check_01 CHECK (version_no > 0), + CONSTRAINT imz_investor_journeys_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_investor_journeys_check_03 CHECK (status IN ('started','active','completed','cancelled')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.journey_steps ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + journey_id uuid NOT NULL CONSTRAINT imz_journey_steps_journey_id_fk REFERENCES public.investor_journeys(id) ON DELETE CASCADE ON UPDATE CASCADE, + step_code text NOT NULL, + sequence_no integer NOT NULL, + status text NOT NULL, + CONSTRAINT imz_journey_steps_pk PRIMARY KEY (id), + CONSTRAINT imz_journey_steps_check_01 CHECK (version_no > 0), + CONSTRAINT imz_journey_steps_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_journey_steps_check_03 CHECK (sequence_no>0), + CONSTRAINT imz_journey_steps_check_04 CHECK (status IN ('pending','active','completed','blocked')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.submissions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + journey_step_id uuid NOT NULL CONSTRAINT imz_submissions_journey_step_id_fk REFERENCES public.journey_steps(id) ON DELETE CASCADE ON UPDATE CASCADE, + submitted_by bigint CONSTRAINT imz_submissions_submitted_by_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + status text NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + submitted_at timestamptz, + CONSTRAINT imz_submissions_pk PRIMARY KEY (id), + CONSTRAINT imz_submissions_check_01 CHECK (version_no > 0), + CONSTRAINT imz_submissions_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_submissions_check_03 CHECK (status IN ('draft','submitted','accepted','rejected')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.aftercare_plans ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + investor_id uuid NOT NULL CONSTRAINT imz_aftercare_plans_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE, + project_id uuid CONSTRAINT imz_aftercare_plans_project_id_fk REFERENCES public.projects(id) ON DELETE SET NULL ON UPDATE CASCADE, + owner_user_id bigint CONSTRAINT imz_aftercare_plans_owner_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + status text NOT NULL, + review_date date, + CONSTRAINT imz_aftercare_plans_pk PRIMARY KEY (id), + CONSTRAINT imz_aftercare_plans_check_01 CHECK (version_no > 0), + CONSTRAINT imz_aftercare_plans_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_aftercare_plans_check_03 CHECK (status IN ('draft','active','closed')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.constraints ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + aftercare_plan_id uuid NOT NULL CONSTRAINT imz_constraints_aftercare_plan_id_fk REFERENCES public.aftercare_plans(id) ON DELETE CASCADE ON UPDATE CASCADE, + category text NOT NULL, + description text NOT NULL, + severity text NOT NULL, + status text NOT NULL, + CONSTRAINT imz_constraints_pk PRIMARY KEY (id), + CONSTRAINT imz_constraints_check_01 CHECK (version_no > 0), + CONSTRAINT imz_constraints_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_constraints_check_03 CHECK (severity IN ('low','medium','high','critical')), + CONSTRAINT imz_constraints_check_04 CHECK (status IN ('open','mitigating','resolved')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.escalations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + constraint_id uuid NOT NULL CONSTRAINT imz_escalations_constraint_id_fk REFERENCES public.constraints(id) ON DELETE CASCADE ON UPDATE CASCADE, + escalated_by bigint CONSTRAINT imz_escalations_escalated_by_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + level text NOT NULL, + status text NOT NULL, + escalated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_escalations_pk PRIMARY KEY (id), + CONSTRAINT imz_escalations_check_01 CHECK (version_no > 0), + CONSTRAINT imz_escalations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_escalations_check_03 CHECK (level IN ('operational','management','executive')), + CONSTRAINT imz_escalations_check_04 CHECK (status IN ('open','acknowledged','closed')) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.escalations DROP CONSTRAINT IF EXISTS imz_escalations_constraint_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.escalations DROP CONSTRAINT IF EXISTS imz_escalations_escalated_by_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.escalations DROP CONSTRAINT IF EXISTS imz_escalations_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.escalations DROP CONSTRAINT IF EXISTS imz_escalations_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.escalations DROP CONSTRAINT IF EXISTS imz_escalations_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.escalations DROP CONSTRAINT IF EXISTS imz_escalations_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.escalations DROP CONSTRAINT IF EXISTS imz_escalations_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.escalations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.constraints DROP CONSTRAINT IF EXISTS imz_constraints_aftercare_plan_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.constraints DROP CONSTRAINT IF EXISTS imz_constraints_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.constraints DROP CONSTRAINT IF EXISTS imz_constraints_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.constraints DROP CONSTRAINT IF EXISTS imz_constraints_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.constraints DROP CONSTRAINT IF EXISTS imz_constraints_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.constraints DROP CONSTRAINT IF EXISTS imz_constraints_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.constraints RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_plans DROP CONSTRAINT IF EXISTS imz_aftercare_plans_investor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_plans DROP CONSTRAINT IF EXISTS imz_aftercare_plans_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_plans DROP CONSTRAINT IF EXISTS imz_aftercare_plans_owner_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_plans DROP CONSTRAINT IF EXISTS imz_aftercare_plans_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_plans DROP CONSTRAINT IF EXISTS imz_aftercare_plans_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_plans DROP CONSTRAINT IF EXISTS imz_aftercare_plans_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_plans DROP CONSTRAINT IF EXISTS imz_aftercare_plans_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.aftercare_plans RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.submissions DROP CONSTRAINT IF EXISTS imz_submissions_journey_step_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.submissions DROP CONSTRAINT IF EXISTS imz_submissions_submitted_by_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.submissions DROP CONSTRAINT IF EXISTS imz_submissions_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.submissions DROP CONSTRAINT IF EXISTS imz_submissions_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.submissions DROP CONSTRAINT IF EXISTS imz_submissions_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.submissions DROP CONSTRAINT IF EXISTS imz_submissions_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.submissions RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_steps DROP CONSTRAINT IF EXISTS imz_journey_steps_journey_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_steps DROP CONSTRAINT IF EXISTS imz_journey_steps_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_steps DROP CONSTRAINT IF EXISTS imz_journey_steps_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_steps DROP CONSTRAINT IF EXISTS imz_journey_steps_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_steps DROP CONSTRAINT IF EXISTS imz_journey_steps_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_steps DROP CONSTRAINT IF EXISTS imz_journey_steps_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.journey_steps RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_journeys DROP CONSTRAINT IF EXISTS imz_investor_journeys_investor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_journeys DROP CONSTRAINT IF EXISTS imz_investor_journeys_opportunity_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_journeys DROP CONSTRAINT IF EXISTS imz_investor_journeys_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_journeys DROP CONSTRAINT IF EXISTS imz_investor_journeys_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_journeys DROP CONSTRAINT IF EXISTS imz_investor_journeys_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_journeys DROP CONSTRAINT IF EXISTS imz_investor_journeys_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.investor_journeys RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_24_009000_investmoz_education.php b/database/migrations/2026_09_24_009000_investmoz_education.php new file mode 100644 index 00000000..28f36b9e --- /dev/null +++ b/database/migrations/2026_09_24_009000_investmoz_education.php @@ -0,0 +1,305 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.skills ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + code text NOT NULL, + name text NOT NULL, + CONSTRAINT imz_skills_pk PRIMARY KEY (id), + CONSTRAINT imz_skills_check_01 CHECK (version_no > 0), + CONSTRAINT imz_skills_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.occupations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + code text NOT NULL, + name text NOT NULL, + CONSTRAINT imz_occupations_pk PRIMARY KEY (id), + CONSTRAINT imz_occupations_check_01 CHECK (version_no > 0), + CONSTRAINT imz_occupations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.training_needs ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + project_id uuid CONSTRAINT imz_training_needs_project_id_fk REFERENCES public.projects(id) ON DELETE SET NULL ON UPDATE CASCADE, + occupation_id uuid CONSTRAINT imz_training_needs_occupation_id_fk REFERENCES public.occupations(id) ON DELETE RESTRICT ON UPDATE CASCADE, + skill_id uuid CONSTRAINT imz_training_needs_skill_id_fk REFERENCES public.skills(id) ON DELETE RESTRICT ON UPDATE CASCADE, + quantity integer NOT NULL, + priority text NOT NULL, + CONSTRAINT imz_training_needs_pk PRIMARY KEY (id), + CONSTRAINT imz_training_needs_check_01 CHECK (version_no > 0), + CONSTRAINT imz_training_needs_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_training_needs_check_03 CHECK (quantity>0), + CONSTRAINT imz_training_needs_check_04 CHECK (priority IN ('Must','Should','Could')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.training_providers ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + organization_id uuid CONSTRAINT imz_training_providers_organization_id_fk REFERENCES public.organizations(id) ON DELETE SET NULL ON UPDATE CASCADE, + name text NOT NULL, + status text NOT NULL, + CONSTRAINT imz_training_providers_pk PRIMARY KEY (id), + CONSTRAINT imz_training_providers_check_01 CHECK (version_no > 0), + CONSTRAINT imz_training_providers_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_training_providers_check_03 CHECK (status IN ('candidate','approved','inactive')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.courses ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + provider_id uuid NOT NULL CONSTRAINT imz_courses_provider_id_fk REFERENCES public.training_providers(id) ON DELETE CASCADE ON UPDATE CASCADE, + code text NOT NULL, + title text NOT NULL, + status text NOT NULL, + CONSTRAINT imz_courses_pk PRIMARY KEY (id), + CONSTRAINT imz_courses_check_01 CHECK (version_no > 0), + CONSTRAINT imz_courses_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_courses_check_03 CHECK (status IN ('draft','published','retired')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.education_opportunities ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + course_id uuid NOT NULL CONSTRAINT imz_education_opportunities_course_id_fk REFERENCES public.courses(id) ON DELETE CASCADE ON UPDATE CASCADE, + geo_location_id uuid CONSTRAINT imz_education_opportunities_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE SET NULL ON UPDATE CASCADE, + start_date date NOT NULL, + end_date date, + capacity integer, + status text NOT NULL, + CONSTRAINT imz_education_opportunities_pk PRIMARY KEY (id), + CONSTRAINT imz_education_opportunities_check_01 CHECK (version_no > 0), + CONSTRAINT imz_education_opportunities_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_education_opportunities_check_03 CHECK (capacity>=0), + CONSTRAINT imz_education_opportunities_check_04 CHECK (status IN ('planned','open','closed','cancelled')), + CONSTRAINT imz_education_opportunities_check_05 CHECK (end_date IS NULL OR end_date>=start_date) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_course_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_geo_location_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_check_05 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.education_opportunities RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.courses DROP CONSTRAINT IF EXISTS imz_courses_provider_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.courses DROP CONSTRAINT IF EXISTS imz_courses_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.courses DROP CONSTRAINT IF EXISTS imz_courses_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.courses DROP CONSTRAINT IF EXISTS imz_courses_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.courses DROP CONSTRAINT IF EXISTS imz_courses_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.courses RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_providers DROP CONSTRAINT IF EXISTS imz_training_providers_organization_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_providers DROP CONSTRAINT IF EXISTS imz_training_providers_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_providers DROP CONSTRAINT IF EXISTS imz_training_providers_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_providers DROP CONSTRAINT IF EXISTS imz_training_providers_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_providers DROP CONSTRAINT IF EXISTS imz_training_providers_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.training_providers RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_occupation_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_skill_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.training_needs RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.occupations DROP CONSTRAINT IF EXISTS imz_occupations_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.occupations DROP CONSTRAINT IF EXISTS imz_occupations_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.occupations DROP CONSTRAINT IF EXISTS imz_occupations_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.occupations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.skills DROP CONSTRAINT IF EXISTS imz_skills_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.skills DROP CONSTRAINT IF EXISTS imz_skills_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.skills DROP CONSTRAINT IF EXISTS imz_skills_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.skills RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_24_009500_investmoz_document_links.php b/database/migrations/2026_09_24_009500_investmoz_document_links.php new file mode 100644 index 00000000..76776f3c --- /dev/null +++ b/database/migrations/2026_09_24_009500_investmoz_document_links.php @@ -0,0 +1,147 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.project_documents ( + project_id uuid NOT NULL CONSTRAINT imz_project_documents_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE, + document_id uuid NOT NULL CONSTRAINT imz_project_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_project_documents_pk PRIMARY KEY (project_id, document_id) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.investor_documents ( + investor_id uuid NOT NULL CONSTRAINT imz_investor_documents_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE, + document_id uuid NOT NULL CONSTRAINT imz_investor_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_investor_documents_pk PRIMARY KEY (investor_id, document_id) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.journey_documents ( + journey_id uuid NOT NULL CONSTRAINT imz_journey_documents_journey_id_fk REFERENCES public.investor_journeys(id) ON DELETE CASCADE ON UPDATE CASCADE, + document_id uuid NOT NULL CONSTRAINT imz_journey_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_journey_documents_pk PRIMARY KEY (journey_id, document_id) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.aftercare_documents ( + aftercare_plan_id uuid NOT NULL CONSTRAINT imz_aftercare_documents_aftercare_plan_id_fk REFERENCES public.aftercare_plans(id) ON DELETE CASCADE ON UPDATE CASCADE, + document_id uuid NOT NULL CONSTRAINT imz_aftercare_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_aftercare_documents_pk PRIMARY KEY (aftercare_plan_id, document_id) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.education_documents ( + education_opportunity_id uuid NOT NULL CONSTRAINT imz_education_documents_education_opportunity_id_fk REFERENCES public.education_opportunities(id) ON DELETE CASCADE ON UPDATE CASCADE, + document_id uuid NOT NULL CONSTRAINT imz_education_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_education_documents_pk PRIMARY KEY (education_opportunity_id, document_id) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_documents DROP CONSTRAINT IF EXISTS imz_education_documents_education_opportunity_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_documents DROP CONSTRAINT IF EXISTS imz_education_documents_document_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_documents DROP CONSTRAINT IF EXISTS imz_education_documents_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.education_documents RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_documents DROP CONSTRAINT IF EXISTS imz_aftercare_documents_aftercare_plan_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_documents DROP CONSTRAINT IF EXISTS imz_aftercare_documents_document_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_documents DROP CONSTRAINT IF EXISTS imz_aftercare_documents_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.aftercare_documents RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_documents DROP CONSTRAINT IF EXISTS imz_journey_documents_journey_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_documents DROP CONSTRAINT IF EXISTS imz_journey_documents_document_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_documents DROP CONSTRAINT IF EXISTS imz_journey_documents_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.journey_documents RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_documents DROP CONSTRAINT IF EXISTS imz_investor_documents_investor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_documents DROP CONSTRAINT IF EXISTS imz_investor_documents_document_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_documents DROP CONSTRAINT IF EXISTS imz_investor_documents_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.investor_documents RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_documents DROP CONSTRAINT IF EXISTS imz_project_documents_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_documents DROP CONSTRAINT IF EXISTS imz_project_documents_document_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_documents DROP CONSTRAINT IF EXISTS imz_project_documents_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.project_documents RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_24_010000_investmoz_governance_ai.php b/database/migrations/2026_09_24_010000_investmoz_governance_ai.php new file mode 100644 index 00000000..074e18ef --- /dev/null +++ b/database/migrations/2026_09_24_010000_investmoz_governance_ai.php @@ -0,0 +1,58 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.audit_events ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + actor_user_id bigint CONSTRAINT imz_audit_events_actor_user_id_fk REFERENCES public.users(id) ON DELETE RESTRICT ON UPDATE RESTRICT, + entity_type text NOT NULL, + entity_id uuid, + action text NOT NULL, + occurred_at timestamptz NOT NULL DEFAULT now(), + before_data jsonb, + after_data jsonb, + CONSTRAINT imz_audit_events_pk PRIMARY KEY (id), + CONSTRAINT imz_audit_events_check_01 CHECK (data_classification IN ('public','internal','confidential','restricted')) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.audit_events DROP CONSTRAINT IF EXISTS imz_audit_events_actor_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.audit_events DROP CONSTRAINT IF EXISTS imz_audit_events_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.audit_events DROP CONSTRAINT IF EXISTS imz_audit_events_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.audit_events RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_24_011000_investmoz_integrations.php b/database/migrations/2026_09_24_011000_investmoz_integrations.php new file mode 100644 index 00000000..568d4c64 --- /dev/null +++ b/database/migrations/2026_09_24_011000_investmoz_integrations.php @@ -0,0 +1,217 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.outbox_events ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + aggregate_type text NOT NULL, + aggregate_id uuid NOT NULL, + event_type text NOT NULL, + payload jsonb NOT NULL, + status text NOT NULL, + available_at timestamptz NOT NULL DEFAULT now(), + published_at timestamptz, + CONSTRAINT imz_outbox_events_pk PRIMARY KEY (id), + CONSTRAINT imz_outbox_events_check_01 CHECK (version_no > 0), + CONSTRAINT imz_outbox_events_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_outbox_events_check_03 CHECK (status IN ('pending','published','failed')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.inbox_events ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + message_id text NOT NULL, + event_type text NOT NULL, + payload jsonb NOT NULL, + status text NOT NULL, + received_at timestamptz NOT NULL DEFAULT now(), + processed_at timestamptz, + CONSTRAINT imz_inbox_events_pk PRIMARY KEY (id), + CONSTRAINT imz_inbox_events_check_01 CHECK (version_no > 0), + CONSTRAINT imz_inbox_events_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_inbox_events_check_03 CHECK (status IN ('received','processed','failed')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.sync_runs ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + integration_name text NOT NULL, + direction text NOT NULL, + status text NOT NULL, + started_at timestamptz NOT NULL DEFAULT now(), + ended_at timestamptz, + totals jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT imz_sync_runs_pk PRIMARY KEY (id), + CONSTRAINT imz_sync_runs_check_01 CHECK (version_no > 0), + CONSTRAINT imz_sync_runs_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_sync_runs_check_03 CHECK (direction IN ('inbound','outbound')), + CONSTRAINT imz_sync_runs_check_04 CHECK (status IN ('running','succeeded','failed','cancelled')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.reconciliation_issues ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + sync_run_id uuid NOT NULL CONSTRAINT imz_reconciliation_issues_sync_run_id_fk REFERENCES public.sync_runs(id) ON DELETE CASCADE ON UPDATE CASCADE, + entity_type text NOT NULL, + external_id_ref text, + issue_type text NOT NULL, + severity text NOT NULL, + status text NOT NULL, + details jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT imz_reconciliation_issues_pk PRIMARY KEY (id), + CONSTRAINT imz_reconciliation_issues_check_01 CHECK (version_no > 0), + CONSTRAINT imz_reconciliation_issues_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_reconciliation_issues_check_03 CHECK (issue_type IN ('missing','duplicate','orphan','checksum','geometry','value')), + CONSTRAINT imz_reconciliation_issues_check_04 CHECK (severity IN ('warning','error')), + CONSTRAINT imz_reconciliation_issues_check_05 CHECK (status IN ('open','accepted','resolved')) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.reconciliation_issues DROP CONSTRAINT IF EXISTS imz_reconciliation_issues_sync_run_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.reconciliation_issues DROP CONSTRAINT IF EXISTS imz_reconciliation_issues_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.reconciliation_issues DROP CONSTRAINT IF EXISTS imz_reconciliation_issues_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.reconciliation_issues DROP CONSTRAINT IF EXISTS imz_reconciliation_issues_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.reconciliation_issues DROP CONSTRAINT IF EXISTS imz_reconciliation_issues_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.reconciliation_issues DROP CONSTRAINT IF EXISTS imz_reconciliation_issues_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.reconciliation_issues DROP CONSTRAINT IF EXISTS imz_reconciliation_issues_check_05 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.reconciliation_issues RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.sync_runs DROP CONSTRAINT IF EXISTS imz_sync_runs_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.sync_runs DROP CONSTRAINT IF EXISTS imz_sync_runs_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.sync_runs DROP CONSTRAINT IF EXISTS imz_sync_runs_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.sync_runs DROP CONSTRAINT IF EXISTS imz_sync_runs_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.sync_runs DROP CONSTRAINT IF EXISTS imz_sync_runs_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.sync_runs RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.inbox_events DROP CONSTRAINT IF EXISTS imz_inbox_events_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.inbox_events DROP CONSTRAINT IF EXISTS imz_inbox_events_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.inbox_events DROP CONSTRAINT IF EXISTS imz_inbox_events_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.inbox_events DROP CONSTRAINT IF EXISTS imz_inbox_events_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.inbox_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.outbox_events DROP CONSTRAINT IF EXISTS imz_outbox_events_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.outbox_events DROP CONSTRAINT IF EXISTS imz_outbox_events_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.outbox_events DROP CONSTRAINT IF EXISTS imz_outbox_events_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.outbox_events DROP CONSTRAINT IF EXISTS imz_outbox_events_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.outbox_events RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_24_012000_investmoz_indexes.php b/database/migrations/2026_09_24_012000_investmoz_indexes.php new file mode 100644 index 00000000..1da6dfff --- /dev/null +++ b/database/migrations/2026_09_24_012000_investmoz_indexes.php @@ -0,0 +1,1606 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_organizations_source_system_external_id_uq ON public.organizations USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_organizations_updated_at_idx ON public.organizations USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_memberships_user_id_idx ON public.memberships USING btree (user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_memberships_organization_id_idx ON public.memberships USING btree (organization_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_memberships_user_id_organization_id_uq ON public.memberships USING btree (user_id, organization_id) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_memberships_source_system_external_id_uq ON public.memberships USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_memberships_updated_at_idx ON public.memberships USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_consents_user_id_idx ON public.consents USING btree (user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_consents_source_system_external_id_uq ON public.consents USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_consents_updated_at_idx ON public.consents USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_consents_status_idx ON public.consents USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_documents_storage_key_uq ON public.documents USING btree (storage_key); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_documents_source_system_external_id_uq ON public.documents USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_documents_updated_at_idx ON public.documents USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_documents_status_idx ON public.documents USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_geo_locations_parent_id_idx ON public.geo_locations USING btree (parent_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_geo_locations_source_system_external_id_uq ON public.geo_locations USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_geo_locations_location_type_code_uq ON public.geo_locations USING btree (location_type, code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_geo_locations_updated_at_idx ON public.geo_locations USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_geo_geometries_geo_location_id_idx ON public.geo_geometries USING btree (geo_location_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_geo_geometries_geo_location_id_geometry_type_uq ON public.geo_geometries USING btree (geo_location_id, geometry_type) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_geo_geometries_source_system_external_id_uq ON public.geo_geometries USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_geo_geometries_geom_idx ON public.geo_geometries USING gist (geom); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_geo_geometries_updated_at_idx ON public.geo_geometries USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_projects_code_uq ON public.projects USING btree (code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_projects_source_system_external_id_uq ON public.projects USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_projects_updated_at_idx ON public.projects USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_projects_status_idx ON public.projects USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_project_locations_project_id_idx ON public.project_locations USING btree (project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_project_locations_geo_location_id_idx ON public.project_locations USING btree (geo_location_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_project_locations_project_id_geo_location_id_role_uq ON public.project_locations USING btree (project_id, geo_location_id, role) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_project_locations_source_system_external_id_uq ON public.project_locations USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_project_locations_updated_at_idx ON public.project_locations USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_economic_clusters_code_uq ON public.economic_clusters USING btree (code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_economic_clusters_source_system_external_id_uq ON public.economic_clusters USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_economic_clusters_updated_at_idx ON public.economic_clusters USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_economic_clusters_status_idx ON public.economic_clusters USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_value_chains_cluster_id_idx ON public.value_chains USING btree (cluster_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_value_chains_cluster_id_code_uq ON public.value_chains USING btree (cluster_id, code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_value_chains_source_system_external_id_uq ON public.value_chains USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_value_chains_updated_at_idx ON public.value_chains USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_value_chain_links_from_value_chain_id_idx ON public.value_chain_links USING btree (from_value_chain_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_value_chain_links_to_value_chain_id_idx ON public.value_chain_links USING btree (to_value_chain_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c ON public.value_chain_links USING btree (from_value_chain_id, to_value_chain_id, link_type) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_value_chain_links_source_system_external_id_uq ON public.value_chain_links USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_value_chain_links_updated_at_idx ON public.value_chain_links USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_cluster_projects_cluster_id_idx ON public.cluster_projects USING btree (cluster_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_cluster_projects_project_id_idx ON public.cluster_projects USING btree (project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_cluster_projects_cluster_id_project_id_uq ON public.cluster_projects USING btree (cluster_id, project_id) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_cluster_projects_source_system_external_id_uq ON public.cluster_projects USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_cluster_projects_updated_at_idx ON public.cluster_projects USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_anchor_project_links_anchor_project_id_idx ON public.anchor_project_links USING btree (anchor_project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_anchor_project_links_cluster_id_idx ON public.anchor_project_links USING btree (cluster_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_anchor_project_links_anchor_project_id_cluster_id_uq ON public.anchor_project_links USING btree (anchor_project_id, cluster_id) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_anchor_project_links_source_system_external_id_uq ON public.anchor_project_links USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_anchor_project_links_updated_at_idx ON public.anchor_project_links USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_complementary_project_links_anchor_project_id_idx ON public.complementary_project_links USING btree (anchor_project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_complementary_project_links_complementary_project_id_idx ON public.complementary_project_links USING btree (complementary_project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_complementary_project_links_anchor_project_id_co_90eddcd25a ON public.complementary_project_links USING btree (anchor_project_id, complementary_project_id, relation_type) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_complementary_project_links_source_system_external_id_uq ON public.complementary_project_links USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_complementary_project_links_updated_at_idx ON public.complementary_project_links USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_development_corridors_code_uq ON public.development_corridors USING btree (code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_development_corridors_source_system_external_id_uq ON public.development_corridors USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_development_corridors_updated_at_idx ON public.development_corridors USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_development_corridors_status_idx ON public.development_corridors USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_locations_corridor_id_idx ON public.corridor_locations USING btree (corridor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_locations_geo_location_id_idx ON public.corridor_locations USING btree (geo_location_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_corridor_locations_corridor_id_geo_location_id_uq ON public.corridor_locations USING btree (corridor_id, geo_location_id) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_corridor_locations_corridor_id_sequence_no_uq ON public.corridor_locations USING btree (corridor_id, sequence_no) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_corridor_locations_source_system_external_id_uq ON public.corridor_locations USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_locations_updated_at_idx ON public.corridor_locations USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_projects_corridor_id_idx ON public.corridor_projects USING btree (corridor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_projects_project_id_idx ON public.corridor_projects USING btree (project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_corridor_projects_corridor_id_project_id_uq ON public.corridor_projects USING btree (corridor_id, project_id) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_corridor_projects_source_system_external_id_uq ON public.corridor_projects USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_projects_updated_at_idx ON public.corridor_projects USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_clusters_corridor_id_idx ON public.corridor_clusters USING btree (corridor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_clusters_cluster_id_idx ON public.corridor_clusters USING btree (cluster_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_corridor_clusters_corridor_id_cluster_id_uq ON public.corridor_clusters USING btree (corridor_id, cluster_id) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_corridor_clusters_source_system_external_id_uq ON public.corridor_clusters USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_clusters_updated_at_idx ON public.corridor_clusters USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investors_organization_id_idx ON public.investors USING btree (organization_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_investors_source_system_external_id_uq ON public.investors USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investors_updated_at_idx ON public.investors USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investors_status_idx ON public.investors USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_contacts_source_system_external_id_uq ON public.contacts USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_contacts_updated_at_idx ON public.contacts USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_contacts_investor_id_idx ON public.investor_contacts USING btree (investor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_contacts_contact_id_idx ON public.investor_contacts USING btree (contact_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_contacts_updated_at_idx ON public.investor_contacts USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_leads_investor_id_idx ON public.leads USING btree (investor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_leads_owner_user_id_idx ON public.leads USING btree (owner_user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_leads_source_system_external_id_uq ON public.leads USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_leads_updated_at_idx ON public.leads USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_leads_status_idx ON public.leads USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_crm_opportunities_lead_id_idx ON public.crm_opportunities USING btree (lead_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_crm_opportunities_project_id_idx ON public.crm_opportunities USING btree (project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_crm_opportunities_owner_user_id_idx ON public.crm_opportunities USING btree (owner_user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_crm_opportunities_source_system_external_id_uq ON public.crm_opportunities USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_crm_opportunities_updated_at_idx ON public.crm_opportunities USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_activities_investor_id_idx ON public.activities USING btree (investor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_activities_opportunity_id_idx ON public.activities USING btree (opportunity_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_activities_actor_user_id_idx ON public.activities USING btree (actor_user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_activities_source_system_external_id_uq ON public.activities USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_activities_updated_at_idx ON public.activities USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_tasks_assignee_user_id_idx ON public.tasks USING btree (assignee_user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_tasks_opportunity_id_idx ON public.tasks USING btree (opportunity_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_tasks_source_system_external_id_uq ON public.tasks USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_tasks_updated_at_idx ON public.tasks USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_tasks_status_idx ON public.tasks USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_journeys_investor_id_idx ON public.investor_journeys USING btree (investor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_journeys_opportunity_id_idx ON public.investor_journeys USING btree (opportunity_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_investor_journeys_source_system_external_id_uq ON public.investor_journeys USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_journeys_updated_at_idx ON public.investor_journeys USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_journeys_status_idx ON public.investor_journeys USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_journey_steps_journey_id_idx ON public.journey_steps USING btree (journey_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_journey_steps_journey_id_sequence_no_uq ON public.journey_steps USING btree (journey_id, sequence_no) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_journey_steps_journey_id_step_code_uq ON public.journey_steps USING btree (journey_id, step_code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_journey_steps_source_system_external_id_uq ON public.journey_steps USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_journey_steps_updated_at_idx ON public.journey_steps USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_journey_steps_status_idx ON public.journey_steps USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_submissions_journey_step_id_idx ON public.submissions USING btree (journey_step_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_submissions_submitted_by_idx ON public.submissions USING btree (submitted_by); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_submissions_source_system_external_id_uq ON public.submissions USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_submissions_updated_at_idx ON public.submissions USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_submissions_status_idx ON public.submissions USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_aftercare_plans_investor_id_idx ON public.aftercare_plans USING btree (investor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_aftercare_plans_project_id_idx ON public.aftercare_plans USING btree (project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_aftercare_plans_owner_user_id_idx ON public.aftercare_plans USING btree (owner_user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_aftercare_plans_source_system_external_id_uq ON public.aftercare_plans USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_aftercare_plans_updated_at_idx ON public.aftercare_plans USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_aftercare_plans_status_idx ON public.aftercare_plans USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_constraints_aftercare_plan_id_idx ON public.constraints USING btree (aftercare_plan_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_constraints_source_system_external_id_uq ON public.constraints USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_constraints_updated_at_idx ON public.constraints USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_constraints_status_idx ON public.constraints USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_escalations_constraint_id_idx ON public.escalations USING btree (constraint_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_escalations_escalated_by_idx ON public.escalations USING btree (escalated_by); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_escalations_source_system_external_id_uq ON public.escalations USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_escalations_updated_at_idx ON public.escalations USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_escalations_status_idx ON public.escalations USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_skills_code_uq ON public.skills USING btree (code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_skills_source_system_external_id_uq ON public.skills USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_skills_updated_at_idx ON public.skills USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_occupations_code_uq ON public.occupations USING btree (code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_occupations_source_system_external_id_uq ON public.occupations USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_occupations_updated_at_idx ON public.occupations USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_training_needs_project_id_idx ON public.training_needs USING btree (project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_training_needs_occupation_id_idx ON public.training_needs USING btree (occupation_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_training_needs_skill_id_idx ON public.training_needs USING btree (skill_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_training_needs_source_system_external_id_uq ON public.training_needs USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_training_needs_updated_at_idx ON public.training_needs USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_training_providers_organization_id_idx ON public.training_providers USING btree (organization_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_training_providers_source_system_external_id_uq ON public.training_providers USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_training_providers_updated_at_idx ON public.training_providers USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_training_providers_status_idx ON public.training_providers USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_courses_provider_id_idx ON public.courses USING btree (provider_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_courses_provider_id_code_uq ON public.courses USING btree (provider_id, code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_courses_source_system_external_id_uq ON public.courses USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_courses_updated_at_idx ON public.courses USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_courses_status_idx ON public.courses USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_education_opportunities_course_id_idx ON public.education_opportunities USING btree (course_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_education_opportunities_geo_location_id_idx ON public.education_opportunities USING btree (geo_location_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_education_opportunities_source_system_external_id_uq ON public.education_opportunities USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_education_opportunities_updated_at_idx ON public.education_opportunities USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_education_opportunities_status_idx ON public.education_opportunities USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_project_documents_project_id_idx ON public.project_documents USING btree (project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_project_documents_document_id_idx ON public.project_documents USING btree (document_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_documents_investor_id_idx ON public.investor_documents USING btree (investor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_documents_document_id_idx ON public.investor_documents USING btree (document_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_journey_documents_journey_id_idx ON public.journey_documents USING btree (journey_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_journey_documents_document_id_idx ON public.journey_documents USING btree (document_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_aftercare_documents_aftercare_plan_id_idx ON public.aftercare_documents USING btree (aftercare_plan_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_aftercare_documents_document_id_idx ON public.aftercare_documents USING btree (document_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_education_documents_education_opportunity_id_idx ON public.education_documents USING btree (education_opportunity_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_education_documents_document_id_idx ON public.education_documents USING btree (document_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_audit_events_actor_user_id_idx ON public.audit_events USING btree (actor_user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_audit_events_source_system_external_id_uq ON public.audit_events USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_outbox_events_source_system_external_id_uq ON public.outbox_events USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_outbox_events_updated_at_idx ON public.outbox_events USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_outbox_events_status_idx ON public.outbox_events USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_inbox_events_message_id_uq ON public.inbox_events USING btree (message_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_inbox_events_source_system_external_id_uq ON public.inbox_events USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_inbox_events_updated_at_idx ON public.inbox_events USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_inbox_events_status_idx ON public.inbox_events USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_sync_runs_source_system_external_id_uq ON public.sync_runs USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_sync_runs_updated_at_idx ON public.sync_runs USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_sync_runs_status_idx ON public.sync_runs USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_reconciliation_issues_sync_run_id_idx ON public.reconciliation_issues USING btree (sync_run_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_reconciliation_issues_source_system_external_id_uq ON public.reconciliation_issues USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_reconciliation_issues_updated_at_idx ON public.reconciliation_issues USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_reconciliation_issues_status_idx ON public.reconciliation_issues USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_geo_geometries_properties_idx ON public.geo_geometries USING gin (properties); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_projects_metadata_idx ON public.projects USING gin (metadata); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_submissions_payload_idx ON public.submissions USING gin (payload); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_audit_events_after_data_idx ON public.audit_events USING gin (after_data); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_outbox_events_status_available_at_idx ON public.outbox_events USING btree (status, available_at) WHERE status = 'pending'; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_reconciliation_issues_sync_run_id_status_idx ON public.reconciliation_issues USING btree (sync_run_id, status); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_reconciliation_issues_sync_run_id_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_outbox_events_status_available_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_audit_events_after_data_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_submissions_payload_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_projects_metadata_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_geometries_properties_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_reconciliation_issues_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_reconciliation_issues_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_reconciliation_issues_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_reconciliation_issues_sync_run_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_sync_runs_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_sync_runs_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_sync_runs_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_inbox_events_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_inbox_events_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_inbox_events_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_inbox_events_message_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_outbox_events_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_outbox_events_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_outbox_events_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_audit_events_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_audit_events_actor_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_education_documents_document_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_education_documents_education_opportunity_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_documents_document_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_documents_aftercare_plan_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_documents_document_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_documents_journey_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_documents_document_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_documents_investor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_project_documents_document_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_project_documents_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_education_opportunities_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_education_opportunities_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_education_opportunities_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_education_opportunities_geo_location_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_education_opportunities_course_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_courses_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_courses_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_courses_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_courses_provider_id_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_courses_provider_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_providers_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_providers_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_providers_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_providers_organization_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_needs_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_needs_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_needs_skill_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_needs_occupation_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_needs_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_occupations_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_occupations_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_occupations_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_skills_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_skills_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_skills_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_escalations_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_escalations_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_escalations_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_escalations_escalated_by_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_escalations_constraint_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_constraints_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_constraints_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_constraints_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_constraints_aftercare_plan_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_plans_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_plans_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_plans_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_plans_owner_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_plans_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_plans_investor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_submissions_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_submissions_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_submissions_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_submissions_submitted_by_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_submissions_journey_step_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_steps_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_steps_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_steps_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_steps_journey_id_step_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_steps_journey_id_sequence_no_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_steps_journey_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_journeys_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_journeys_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_journeys_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_journeys_opportunity_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_journeys_investor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_tasks_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_tasks_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_tasks_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_tasks_opportunity_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_tasks_assignee_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_activities_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_activities_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_activities_actor_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_activities_opportunity_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_activities_investor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_crm_opportunities_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_crm_opportunities_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_crm_opportunities_owner_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_crm_opportunities_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_crm_opportunities_lead_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_leads_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_leads_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_leads_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_leads_owner_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_leads_investor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_contacts_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_contacts_contact_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_contacts_investor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_contacts_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_contacts_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investors_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investors_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investors_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investors_organization_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_clusters_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_clusters_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_clusters_corridor_id_cluster_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_clusters_cluster_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_clusters_corridor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_projects_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_projects_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_projects_corridor_id_project_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_projects_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_projects_corridor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_locations_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_locations_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_locations_corridor_id_sequence_no_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_locations_corridor_id_geo_location_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_locations_geo_location_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_locations_corridor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_development_corridors_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_development_corridors_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_development_corridors_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_development_corridors_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_complementary_project_links_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_complementary_project_links_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_complementary_project_links_anchor_project_id_co_90eddcd25a RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_complementary_project_links_complementary_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_complementary_project_links_anchor_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_anchor_project_links_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_anchor_project_links_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_anchor_project_links_anchor_project_id_cluster_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_anchor_project_links_cluster_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_anchor_project_links_anchor_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_cluster_projects_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_cluster_projects_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_cluster_projects_cluster_id_project_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_cluster_projects_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_cluster_projects_cluster_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chain_links_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chain_links_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chain_links_to_value_chain_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chain_links_from_value_chain_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chains_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chains_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chains_cluster_id_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chains_cluster_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_economic_clusters_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_economic_clusters_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_economic_clusters_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_economic_clusters_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_project_locations_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_project_locations_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_project_locations_project_id_geo_location_id_role_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_project_locations_geo_location_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_project_locations_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_projects_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_projects_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_projects_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_projects_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_geometries_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_geometries_geom_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_geometries_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_geometries_geo_location_id_geometry_type_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_geometries_geo_location_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_locations_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_locations_location_type_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_locations_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_locations_parent_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_documents_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_documents_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_documents_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_documents_storage_key_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_consents_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_consents_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_consents_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_consents_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_memberships_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_memberships_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_memberships_user_id_organization_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_memberships_organization_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_memberships_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_organizations_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_organizations_source_system_external_id_uq RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_24_013000_investmoz_triggers.php b/database/migrations/2026_09_24_013000_investmoz_triggers.php new file mode 100644 index 00000000..4214a071 --- /dev/null +++ b/database/migrations/2026_09_24_013000_investmoz_triggers.php @@ -0,0 +1,390 @@ +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_organizations_updated BEFORE UPDATE ON public.organizations FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_memberships_updated BEFORE UPDATE ON public.memberships FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_consents_updated BEFORE UPDATE ON public.consents FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_documents_updated BEFORE UPDATE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_geo_locations_updated BEFORE UPDATE ON public.geo_locations FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_geo_geometries_updated BEFORE UPDATE ON public.geo_geometries FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_projects_updated BEFORE UPDATE ON public.projects FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_project_locations_updated BEFORE UPDATE ON public.project_locations FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_economic_clusters_updated BEFORE UPDATE ON public.economic_clusters FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_value_chains_updated BEFORE UPDATE ON public.value_chains FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_value_chain_links_updated BEFORE UPDATE ON public.value_chain_links FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_cluster_projects_updated BEFORE UPDATE ON public.cluster_projects FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_anchor_project_links_updated BEFORE UPDATE ON public.anchor_project_links FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_complementary_project_links_updated BEFORE UPDATE ON public.complementary_project_links FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_development_corridors_updated BEFORE UPDATE ON public.development_corridors FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_corridor_locations_updated BEFORE UPDATE ON public.corridor_locations FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_corridor_projects_updated BEFORE UPDATE ON public.corridor_projects FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_corridor_clusters_updated BEFORE UPDATE ON public.corridor_clusters FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_investors_updated BEFORE UPDATE ON public.investors FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_contacts_updated BEFORE UPDATE ON public.contacts FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_investor_contacts_updated BEFORE UPDATE ON public.investor_contacts FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_leads_updated BEFORE UPDATE ON public.leads FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_crm_opportunities_updated BEFORE UPDATE ON public.crm_opportunities FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_activities_updated BEFORE UPDATE ON public.activities FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_tasks_updated BEFORE UPDATE ON public.tasks FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_investor_journeys_updated BEFORE UPDATE ON public.investor_journeys FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_journey_steps_updated BEFORE UPDATE ON public.journey_steps FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_submissions_updated BEFORE UPDATE ON public.submissions FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_aftercare_plans_updated BEFORE UPDATE ON public.aftercare_plans FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_constraints_updated BEFORE UPDATE ON public.constraints FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_escalations_updated BEFORE UPDATE ON public.escalations FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_skills_updated BEFORE UPDATE ON public.skills FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_occupations_updated BEFORE UPDATE ON public.occupations FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_training_needs_updated BEFORE UPDATE ON public.training_needs FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_training_providers_updated BEFORE UPDATE ON public.training_providers FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_courses_updated BEFORE UPDATE ON public.courses FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_education_opportunities_updated BEFORE UPDATE ON public.education_opportunities FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_outbox_events_updated BEFORE UPDATE ON public.outbox_events FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_inbox_events_updated BEFORE UPDATE ON public.inbox_events FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_sync_runs_updated BEFORE UPDATE ON public.sync_runs FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_reconciliation_issues_updated BEFORE UPDATE ON public.reconciliation_issues FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_audit_events_immutable BEFORE UPDATE OR DELETE OR TRUNCATE ON public.audit_events FOR EACH STATEMENT EXECUTE FUNCTION public.investmoz_reject_audit_change(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_outbox_events_envelope BEFORE UPDATE OR DELETE ON public.outbox_events FOR EACH ROW EXECUTE FUNCTION public.investmoz_protect_event_envelope(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_outbox_events_no_truncate BEFORE TRUNCATE ON public.outbox_events FOR EACH STATEMENT EXECUTE FUNCTION public.investmoz_reject_audit_change(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_inbox_events_envelope BEFORE UPDATE OR DELETE ON public.inbox_events FOR EACH ROW EXECUTE FUNCTION public.investmoz_protect_event_envelope(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_inbox_events_no_truncate BEFORE TRUNCATE ON public.inbox_events FOR EACH STATEMENT EXECUTE FUNCTION public.investmoz_reject_audit_change(); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_inbox_events_no_truncate ON public.inbox_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_inbox_events_envelope ON public.inbox_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_outbox_events_no_truncate ON public.outbox_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_outbox_events_envelope ON public.outbox_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_audit_events_immutable ON public.audit_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_reconciliation_issues_updated ON public.reconciliation_issues RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_sync_runs_updated ON public.sync_runs RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_inbox_events_updated ON public.inbox_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_outbox_events_updated ON public.outbox_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_education_opportunities_updated ON public.education_opportunities RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_courses_updated ON public.courses RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_training_providers_updated ON public.training_providers RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_training_needs_updated ON public.training_needs RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_occupations_updated ON public.occupations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_skills_updated ON public.skills RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_escalations_updated ON public.escalations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_constraints_updated ON public.constraints RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_aftercare_plans_updated ON public.aftercare_plans RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_submissions_updated ON public.submissions RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_journey_steps_updated ON public.journey_steps RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_investor_journeys_updated ON public.investor_journeys RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_tasks_updated ON public.tasks RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_activities_updated ON public.activities RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_crm_opportunities_updated ON public.crm_opportunities RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_leads_updated ON public.leads RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_investor_contacts_updated ON public.investor_contacts RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_contacts_updated ON public.contacts RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_investors_updated ON public.investors RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_corridor_clusters_updated ON public.corridor_clusters RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_corridor_projects_updated ON public.corridor_projects RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_corridor_locations_updated ON public.corridor_locations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_development_corridors_updated ON public.development_corridors RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_complementary_project_links_updated ON public.complementary_project_links RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_anchor_project_links_updated ON public.anchor_project_links RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_cluster_projects_updated ON public.cluster_projects RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_value_chain_links_updated ON public.value_chain_links RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_value_chains_updated ON public.value_chains RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_economic_clusters_updated ON public.economic_clusters RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_project_locations_updated ON public.project_locations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_projects_updated ON public.projects RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_geo_geometries_updated ON public.geo_geometries RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_geo_locations_updated ON public.geo_locations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_documents_updated ON public.documents RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_consents_updated ON public.consents RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_memberships_updated ON public.memberships RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_organizations_updated ON public.organizations RESTRICT; +SQL); + } +}; diff --git a/database/migrations/2026_09_25_100000_extend_crm_intake.php b/database/migrations/2026_09_25_100000_extend_crm_intake.php new file mode 100644 index 00000000..109677a9 --- /dev/null +++ b/database/migrations/2026_09_25_100000_extend_crm_intake.php @@ -0,0 +1,34 @@ +foreignId('interest_request_id')->nullable()->unique()->constrained('interest_requests')->restrictOnDelete(); + $table->text('first_source')->nullable(); + $table->text('last_source')->nullable(); + $table->text('qualification_reason')->nullable(); + }); + Schema::table('tasks', function (Blueprint $table) { + $table->foreignUuid('lead_id')->nullable()->constrained('leads')->restrictOnDelete(); + $table->index(['lead_id', 'status', 'due_at']); + }); + } + + public function down(): void + { + Schema::table('tasks', function (Blueprint $table) { + $table->dropIndex(['lead_id', 'status', 'due_at']); + $table->dropConstrainedForeignId('lead_id'); + }); + Schema::table('leads', function (Blueprint $table) { + $table->dropConstrainedForeignId('interest_request_id'); + $table->dropColumn(['first_source', 'last_source', 'qualification_reason']); + }); + } +}; diff --git a/database/migrations/2026_09_25_110000_create_investment_gaps.php b/database/migrations/2026_09_25_110000_create_investment_gaps.php new file mode 100644 index 00000000..4f4de6ce --- /dev/null +++ b/database/migrations/2026_09_25_110000_create_investment_gaps.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->foreignUuid('cluster_id')->constrained('economic_clusters')->restrictOnDelete(); + $table->foreignUuid('value_chain_id')->constrained('value_chains')->restrictOnDelete(); + $table->string('title', 200); + $table->text('location'); + $table->text('required_capacity'); + $table->text('rationale'); + $table->string('status', 30)->default('identified'); + $table->text('call_to_action'); + $table->timestampsTz(); + $table->softDeletesTz(); + $table->index(['cluster_id', 'status']); + }); + DB::statement("ALTER TABLE investment_gaps ADD CONSTRAINT investment_gaps_status_check CHECK (status IN ('identified','in_review','resolved','withdrawn'))"); + } + + public function down(): void + { + Schema::dropIfExists('investment_gaps'); + } +}; diff --git a/database/migrations/2026_09_25_120000_add_interest_submission_receipts.php b/database/migrations/2026_09_25_120000_add_interest_submission_receipts.php new file mode 100644 index 00000000..a9bc8212 --- /dev/null +++ b/database/migrations/2026_09_25_120000_add_interest_submission_receipts.php @@ -0,0 +1,32 @@ +uuid('integration_id')->nullable()->unique(); + }); + Schema::create('interest_submission_receipts', function (Blueprint $table) { + $table->char('key_hash', 64)->primary(); + $table->char('payload_hash', 64); + $table->foreignId('interest_request_id')->constrained('interest_requests')->restrictOnDelete(); + $table->timestampTz('created_at'); + }); + } + + public function down(): void + { + if (DB::table('interest_requests')->whereNotNull('integration_id')->exists() + || DB::table('interest_submission_receipts')->exists()) { + throw new RuntimeException('Preserve submission identities and outbox links: reconcile captured events before schema rollback.'); + } + Schema::dropIfExists('interest_submission_receipts'); + Schema::table('interest_requests', fn (Blueprint $table) => $table->dropColumn('integration_id')); + } +}; diff --git a/database/migrations/2026_09_25_130000_investment_portfolio_journeys.php b/database/migrations/2026_09_25_130000_investment_portfolio_journeys.php new file mode 100644 index 00000000..9b0a2a6c --- /dev/null +++ b/database/migrations/2026_09_25_130000_investment_portfolio_journeys.php @@ -0,0 +1,58 @@ +uuid('id')->primary(); + $table->foreignUuid('project_id')->nullable()->unique()->constrained('projects')->restrictOnDelete(); + $table->foreignUuid('cluster_id')->nullable()->unique()->constrained('economic_clusters')->restrictOnDelete(); + $table->foreignUuid('corridor_id')->nullable()->unique()->constrained('development_corridors')->restrictOnDelete(); + $table->string('title'); + $table->jsonb('content'); + $table->text('source'); + $table->string('publication_status')->default('draft'); + $table->foreignId('editor_id')->nullable()->constrained('users')->restrictOnDelete(); + $table->foreignId('reviewer_id')->nullable()->constrained('users')->restrictOnDelete(); + $table->text('approval_evidence')->nullable(); + $table->timestampTz('published_at')->nullable(); + $table->timestampsTz(); + $table->softDeletesTz(); + }); + DB::statement("ALTER TABLE investment_dossiers ADD CONSTRAINT dossier_one_entity CHECK (num_nonnulls(project_id, cluster_id, corridor_id) = 1), ADD CONSTRAINT dossier_publication CHECK (publication_status IN ('draft','published')), ADD CONSTRAINT dossier_review CHECK (publication_status <> 'published' OR (editor_id IS NOT NULL AND reviewer_id IS NOT NULL AND editor_id <> reviewer_id AND approval_evidence IS NOT NULL AND published_at IS NOT NULL))"); + Schema::create('investor_accounts', function (Blueprint $table) { + $table->foreignId('user_id')->primary()->constrained('users')->restrictOnDelete(); + $table->foreignUuid('investor_id')->unique()->constrained('investors')->restrictOnDelete(); + $table->timestampsTz(); + }); + Schema::table('investor_journeys', function (Blueprint $table) { + $table->foreignId('user_id')->nullable()->constrained('users')->restrictOnDelete(); + $table->foreignUuid('dossier_id')->nullable()->constrained('investment_dossiers')->restrictOnDelete(); + $table->foreignUuid('origin_dossier_id')->nullable()->constrained('investment_dossiers')->restrictOnDelete(); + $table->foreignId('interest_request_id')->nullable()->constrained('interest_requests')->restrictOnDelete(); + $table->unique(['user_id', 'dossier_id']); + }); + } + + public function down(): void + { + if (DB::table('investment_dossiers')->exists() || DB::table('investor_accounts')->exists()) { + throw new RuntimeException('Preserve portfolio and investor data: reconcile before schema rollback.'); + } + Schema::table('investor_journeys', function (Blueprint $table) { + $table->dropUnique(['user_id', 'dossier_id']); + $table->dropConstrainedForeignId('interest_request_id'); + $table->dropConstrainedForeignId('dossier_id'); + $table->dropConstrainedForeignId('origin_dossier_id'); + $table->dropConstrainedForeignId('user_id'); + }); + Schema::dropIfExists('investor_accounts'); + Schema::dropIfExists('investment_dossiers'); + } +}; diff --git a/database/migrations/2026_09_26_100000_education_intake.php b/database/migrations/2026_09_26_100000_education_intake.php new file mode 100644 index 00000000..0ff8e1d5 --- /dev/null +++ b/database/migrations/2026_09_26_100000_education_intake.php @@ -0,0 +1,54 @@ +uuid('id')->primary(); + $table->foreignUuid('occupation_id')->constrained('occupations')->restrictOnDelete(); + $table->foreignUuid('skill_id')->constrained('skills')->restrictOnDelete(); + $table->foreignUuid('geo_location_id')->constrained('geo_locations')->restrictOnDelete(); + if ($name === 'labour_demands') { + $table->foreignUuid('project_id')->constrained('projects')->restrictOnDelete(); + $table->string('employer'); + } else { + $table->foreignUuid('course_id')->constrained('courses')->restrictOnDelete(); + } + $table->string('qualification'); + $table->unsignedInteger('quantity'); + $table->date('period_start'); + $table->date('period_end'); + $table->text('source'); + $table->date('reference_date'); + $table->date('valid_until'); + $table->string('status')->default('submitted'); + $table->unsignedInteger('version_no')->default(1); + $table->foreignId('submitted_by')->constrained('users')->restrictOnDelete(); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete(); + $table->text('review_reason')->nullable(); + $table->timestampsTz(); + $table->softDeletesTz(); + $table->index(['status', 'valid_until']); + }); + DB::statement("ALTER TABLE {$name} ADD CONSTRAINT {$name}_valid CHECK (quantity > 0 AND version_no > 0 AND period_end >= period_start AND valid_until >= reference_date AND status IN ('submitted','reviewed','rejected') AND (status = 'submitted' OR (reviewed_by IS NOT NULL AND reviewed_by <> submitted_by AND review_reason IS NOT NULL)))"); + } + } + + public function down(): void + { + foreach (['training_capacities', 'labour_demands'] as $table) { + if (DB::table($table)->exists()) { + throw new RuntimeException('Preserve education submissions and reconcile before rollback.'); + } + } + Schema::dropIfExists('training_capacities'); + Schema::dropIfExists('labour_demands'); + } +}; diff --git a/database/migrations/2026_09_26_110000_education_investment_proposals.php b/database/migrations/2026_09_26_110000_education_investment_proposals.php new file mode 100644 index 00000000..8e28a9a3 --- /dev/null +++ b/database/migrations/2026_09_26_110000_education_investment_proposals.php @@ -0,0 +1,36 @@ +uuid('id')->primary(); + $table->foreignUuid('labour_demand_id')->unique()->constrained('labour_demands')->restrictOnDelete(); + $table->unsignedInteger('demand_version'); + $table->foreignId('opportunity_id')->unique()->constrained('opportunities')->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->text('intervention'); + $table->text('equipment_needs')->nullable(); + $table->text('trainer_needs')->nullable(); + $table->text('funding_plan')->nullable(); + $table->text('risks'); + $table->text('source'); + $table->timestampsTz(); + $table->softDeletesTz(); + }); + } + + public function down(): void + { + if (DB::table('education_investment_proposals')->exists()) { + throw new RuntimeException('Preserve educational proposal links and reconcile before rollback.'); + } + Schema::dropIfExists('education_investment_proposals'); + } +}; diff --git a/database/migrations/2026_09_26_120000_investmoz_ai_foundation.php b/database/migrations/2026_09_26_120000_investmoz_ai_foundation.php new file mode 100644 index 00000000..74b447b2 --- /dev/null +++ b/database/migrations/2026_09_26_120000_investmoz_ai_foundation.php @@ -0,0 +1,96 @@ +string('capability', 30)->primary(); + $table->boolean('suspended')->default(false); + $table->timestampsTz(); + }); + foreach (['assistant', 'search', 'recommend', 'classify', 'quality', 'sla', 'summary'] as $capability) { + DB::table('ai_controls')->insert(['capability' => $capability, 'suspended' => false, 'created_at' => now(), 'updated_at' => now()]); + } + Schema::create('ai_sources', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignUuid('document_id')->constrained('documents')->restrictOnDelete(); + $table->unsignedInteger('document_version'); + $table->char('document_checksum', 64); + $table->string('title', 200); + $table->string('language', 2); + $table->string('nature', 20); + $table->text('source_url'); + $table->string('source_owner', 200); + $table->text('evidence_reference'); + $table->date('source_date'); + $table->date('valid_until'); + $table->string('purpose', 50)->default('investment_information'); + $table->string('status', 20)->default('pending'); + $table->foreignId('submitted_by')->constrained('users')->restrictOnDelete(); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete(); + $table->timestampTz('reviewed_at')->nullable(); + $table->timestampsTz(); + $table->unique(['document_id', 'document_version']); + $table->index(['status', 'valid_until']); + }); + DB::statement("ALTER TABLE ai_sources ADD CONSTRAINT ai_sources_valid CHECK (document_version > 0 AND language IN ('pt','en') AND nature IN ('official','historical','illustrative','pending') AND status IN ('pending','approved','revoked') AND valid_until >= source_date AND (status <> 'approved' OR (reviewed_by IS NOT NULL AND reviewed_by <> submitted_by AND reviewed_at IS NOT NULL)))"); + Schema::create('ai_chunks', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignUuid('source_id')->constrained('ai_sources')->restrictOnDelete(); + $table->unsignedInteger('position'); + $table->string('language', 2); + $table->text('content'); + $table->char('checksum', 64); + $table->unique(['source_id', 'position']); + }); + DB::statement("ALTER TABLE ai_chunks ADD COLUMN lexical tsvector GENERATED ALWAYS AS (CASE WHEN language = 'pt' THEN to_tsvector('portuguese', content) ELSE to_tsvector('english', content) END) STORED"); + DB::statement('CREATE INDEX ai_chunks_lexical_idx ON ai_chunks USING gin (lexical)'); + Schema::create('ai_runs', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignId('actor_id')->constrained('users')->restrictOnDelete(); + $table->string('capability', 30); + $table->string('purpose', 50); + $table->string('mode', 40); + $table->string('rules_version', 80); + $table->string('prompt_version', 80); + $table->char('input_hash', 64); + $table->text('result'); + $table->jsonb('source_ids')->default('[]'); + $table->unsignedInteger('latency_ms'); + $table->boolean('review_requested')->default(false); + $table->string('review_status', 20)->default('pending'); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete(); + $table->timestampTz('reviewed_at')->nullable(); + $table->unsignedInteger('version_no')->default(1); + $table->timestampsTz(); + $table->index(['actor_id', 'created_at']); + }); + DB::statement("ALTER TABLE ai_runs ADD CONSTRAINT ai_runs_review_valid CHECK (review_status IN ('pending','accepted','rejected') AND version_no > 0 AND (review_status = 'pending' OR (reviewed_by IS NOT NULL AND reviewed_by <> actor_id AND reviewed_at IS NOT NULL)))"); + Schema::create('ai_feedback', function (Blueprint $table) { + $table->id(); + $table->foreignUuid('run_id')->constrained('ai_runs')->restrictOnDelete(); + $table->foreignId('user_id')->constrained('users')->restrictOnDelete(); + $table->string('rating', 30); + $table->timestampsTz(); + $table->unique(['run_id', 'user_id']); + }); + } + + public function down(): void + { + foreach (['ai_sources', 'ai_runs', 'ai_feedback'] as $table) { + if (DB::table($table)->exists()) { + throw new RuntimeException('Preserve AI evidence and reviews; disable the feature instead of deleting data.'); + } + } + foreach (['ai_feedback', 'ai_runs', 'ai_chunks', 'ai_sources', 'ai_controls'] as $table) { + Schema::dropIfExists($table); + } + } +}; diff --git a/database/migrations/2026_09_26_140000_portfolio_owner_approval.php b/database/migrations/2026_09_26_140000_portfolio_owner_approval.php new file mode 100644 index 00000000..24add81a --- /dev/null +++ b/database/migrations/2026_09_26_140000_portfolio_owner_approval.php @@ -0,0 +1,22 @@ + 'published' OR (approval_evidence IS NOT NULL AND length(trim(approval_evidence)) >= 15 AND published_at IS NOT NULL AND (reviewer_id IS NOT NULL OR approval_basis = 'document_owner')))"); + } + + public function down(): void + { + if (DB::table('investment_dossiers')->where('publication_status', 'published')->where(function ($query) { + $query->whereNull('editor_id')->orWhereNull('reviewer_id')->orWhereColumn('editor_id', 'reviewer_id'); + })->exists()) { + throw new RuntimeException('Preserve owner approvals before restoring the two-reviewer policy.'); + } + DB::statement("ALTER TABLE investment_dossiers DROP CONSTRAINT dossier_review, DROP CONSTRAINT dossier_approval_basis, DROP COLUMN approval_basis, ADD CONSTRAINT dossier_review CHECK (publication_status <> 'published' OR (editor_id IS NOT NULL AND reviewer_id IS NOT NULL AND editor_id <> reviewer_id AND approval_evidence IS NOT NULL AND published_at IS NOT NULL))"); + } +}; diff --git a/database/migrations/2026_09_27_120000_public_portal_services.php b/database/migrations/2026_09_27_120000_public_portal_services.php new file mode 100644 index 00000000..4c25bffa --- /dev/null +++ b/database/migrations/2026_09_27_120000_public_portal_services.php @@ -0,0 +1,47 @@ +id(); + $table->foreignUuid('document_id')->constrained('documents')->restrictOnDelete(); + $table->string('title'); + $table->string('category'); + $table->string('topic'); + $table->boolean('legislation')->default(false); + $table->boolean('essential')->default(false); + $table->boolean('published')->default(false); + $table->text('source_reference'); + $table->foreignId('approved_by')->constrained('users')->restrictOnDelete(); + $table->timestampsTz(); + }); + Schema::create('portal_partnerships', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained('users')->restrictOnDelete(); + $table->string('title'); + $table->string('sector'); + $table->string('province'); + $table->text('offer'); + $table->text('seeking'); + $table->string('status')->default('pending'); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete(); + $table->timestampsTz(); + }); + } + + public function down(): void + { + if (DB::table('portal_resources')->exists() || DB::table('portal_partnerships')->exists()) { + throw new RuntimeException('Preserve portal resources and partnership submissions before rollback.'); + } + Schema::dropIfExists('portal_partnerships'); + Schema::dropIfExists('portal_resources'); + } +}; diff --git a/database/migrations/2026_09_27_140000_investment_unification.php b/database/migrations/2026_09_27_140000_investment_unification.php new file mode 100644 index 00000000..d3e0f15e --- /dev/null +++ b/database/migrations/2026_09_27_140000_investment_unification.php @@ -0,0 +1,111 @@ +id(); + $t->string('group'); + $t->string('code'); + $t->string('label'); + $t->boolean('active')->default(true); + $t->boolean('requires_description')->default(false); + $t->unsignedInteger('sort_order')->default(0); + $t->timestampsTz(); + $t->unique(['group', 'code']); + }); + Schema::create('opportunity_profiles', function (Blueprint $t) { + $t->foreignId('opportunity_id')->primary()->constrained('opportunities')->restrictOnDelete(); + $t->boolean('is_education')->default(false)->index(); + $t->unsignedInteger('version')->default(1); + $t->text('business_model')->nullable(); + $t->text('requirements')->nullable(); + $t->text('risks')->nullable(); + $t->date('target_date')->nullable(); + $t->text('source_reference')->nullable(); + $t->timestampsTz(); + }); + foreach (['clusters' => ['cluster_id', 'economic_clusters'], 'corridors' => ['corridor_id', 'development_corridors'], + 'projects' => ['project_id', 'projects'], 'locations' => ['geo_location_id', 'geo_locations']] as $name => [$foreign, $target]) { + Schema::create('opportunity_'.$name, function (Blueprint $t) use ($foreign, $target) { + $t->foreignId('opportunity_id')->constrained('opportunities')->restrictOnDelete(); + $t->foreignUuid($foreign)->constrained($target)->restrictOnDelete(); + $t->primary(['opportunity_id', $foreign]); + $t->index($foreign); + }); + } + Schema::create('opportunity_facets', function (Blueprint $t) { + $t->foreignId('opportunity_id')->constrained('opportunities')->restrictOnDelete(); + $t->foreignId('option_id')->constrained('investment_reference_options')->restrictOnDelete(); + $t->primary(['opportunity_id', 'option_id']); + $t->index('option_id'); + }); + Schema::create('investment_submissions', function (Blueprint $t) { + $t->uuid('id')->primary(); + $t->foreignId('user_id')->constrained('users')->restrictOnDelete(); + $t->foreignId('opportunity_id')->nullable()->constrained('opportunities')->restrictOnDelete(); + $t->foreignUuid('dossier_id')->nullable()->constrained('investment_dossiers')->restrictOnDelete(); + $t->foreignId('interest_request_id')->nullable()->unique()->constrained('interest_requests')->restrictOnDelete(); + $t->text('payload'); + $t->string('status')->default('draft')->index(); + $t->unsignedInteger('version')->default(1); + $t->timestampsTz(); + $t->index(['user_id', 'status']); + }); + DB::statement("ALTER TABLE investment_submissions ADD CONSTRAINT submission_status CHECK(status IN ('draft','submitted')), ADD CONSTRAINT submission_target CHECK(num_nonnulls(opportunity_id,dossier_id)<=1), ADD CONSTRAINT submission_receipt CHECK ((status='draft' AND interest_request_id IS NULL) OR (status='submitted' AND interest_request_id IS NOT NULL))"); + Schema::create('investment_specific_needs', function (Blueprint $t) { + $t->id(); + $t->foreignUuid('submission_id')->constrained('investment_submissions')->restrictOnDelete(); + $t->foreignId('category_id')->constrained('investment_reference_options')->restrictOnDelete(); + $t->foreignId('option_id')->constrained('investment_reference_options')->restrictOnDelete(); + $t->foreignId('unit_id')->nullable()->constrained('investment_reference_options')->restrictOnDelete(); + $t->foreignId('priority_id')->nullable()->constrained('investment_reference_options')->restrictOnDelete(); + $t->decimal('quantity', 16, 2)->nullable(); + $t->date('deadline')->nullable(); + $t->text('details'); + $t->timestampsTz(); + }); + DB::statement('ALTER TABLE investment_specific_needs ADD CONSTRAINT need_quantity CHECK(quantity IS NULL OR quantity>=0)'); + Schema::create('investment_submission_attachments', function (Blueprint $t) { + $t->uuid('id')->primary(); + $t->foreignUuid('submission_id')->constrained('investment_submissions')->restrictOnDelete(); + $t->foreignUuid('document_id')->constrained('documents')->restrictOnDelete(); + $t->timestampsTz(); + }); + Schema::create('investment_submission_events', function (Blueprint $t) { + $t->id(); + $t->foreignUuid('submission_id')->constrained('investment_submissions')->restrictOnDelete(); + $t->foreignId('actor_id')->constrained('users')->restrictOnDelete(); + $t->string('action'); + $t->unsignedInteger('version'); + $t->timestampTz('created_at')->useCurrent(); + }); + Schema::create('opportunity_favourites', function (Blueprint $t) { + $t->foreignId('user_id')->constrained('users')->restrictOnDelete(); + $t->foreignId('opportunity_id')->constrained('opportunities')->restrictOnDelete(); + $t->timestampsTz(); + $t->primary(['user_id', 'opportunity_id']); + }); + } + + public function down(): void + { + $tables = ['opportunity_favourites', 'investment_submission_events', 'investment_submission_attachments', 'investment_specific_needs', + 'investment_submissions', 'opportunity_facets', 'opportunity_locations', 'opportunity_projects', 'opportunity_corridors', 'opportunity_clusters', + 'opportunity_profiles', 'investment_reference_options']; + foreach ($tables as $table) { + if (DB::table($table)->exists()) { + throw new RuntimeException('Preserve investment unification data before rollback.'); + } + } + foreach ($tables as $table) { + Schema::dropIfExists($table); + } + } +}; diff --git a/database/migrations/2026_09_27_150000_investor_workspace.php b/database/migrations/2026_09_27_150000_investor_workspace.php new file mode 100644 index 00000000..698fd1a2 --- /dev/null +++ b/database/migrations/2026_09_27_150000_investor_workspace.php @@ -0,0 +1,71 @@ +foreignId('user_id')->primary()->constrained('users')->restrictOnDelete(); + $t->text('payload'); + $t->unsignedInteger('version')->default(1); + $t->timestampsTz(); + }); + Schema::create('investment_company_declarations', function (Blueprint $t) { + $t->uuid('id')->primary(); + $t->foreignId('user_id')->constrained('users')->restrictOnDelete(); + $t->text('payload'); + $t->unsignedInteger('version')->default(1); + $t->timestampsTz(); + $t->index('user_id'); + }); + Schema::create('investment_messages', function (Blueprint $t) { + $t->uuid('id')->primary(); + $t->foreignUuid('submission_id')->constrained('investment_submissions')->restrictOnDelete(); + $t->foreignId('actor_id')->constrained('users')->restrictOnDelete(); + $t->uuid('client_key'); + $t->string('sender_role'); + $t->text('body'); + $t->timestampTz('created_at')->useCurrent(); + $t->unique(['submission_id', 'actor_id', 'client_key']); + }); + DB::statement("ALTER TABLE investment_messages ADD CONSTRAINT investment_message_role CHECK(sender_role IN ('investor','support'))"); + Schema::create('investment_notifications', function (Blueprint $t) { + $t->uuid('id')->primary(); + $t->foreignId('user_id')->constrained('users')->restrictOnDelete(); + $t->foreignUuid('submission_id')->constrained('investment_submissions')->restrictOnDelete(); + $t->string('kind'); + $t->text('payload'); + $t->string('event_key'); + $t->timestampTz('read_at')->nullable(); + $t->timestampTz('created_at')->useCurrent(); + $t->unique(['user_id', 'event_key']); + $t->index(['user_id', 'read_at']); + }); + Schema::create('investment_submission_revisions', function (Blueprint $t) { + $t->id(); + $t->foreignUuid('submission_id')->constrained('investment_submissions')->restrictOnDelete(); + $t->unsignedInteger('version'); + $t->text('snapshot'); + $t->timestampTz('created_at')->useCurrent(); + $t->unique(['submission_id', 'version']); + }); + } + + public function down(): void + { + $tables = ['investment_submission_revisions', 'investment_notifications', 'investment_messages', 'investment_company_declarations', 'investment_accounts']; + foreach ($tables as $table) { + if (DB::table($table)->exists()) { + throw new RuntimeException('Preserve investor workspace data before rollback.'); + } + } + foreach ($tables as $table) { + Schema::dropIfExists($table); + } + } +}; diff --git a/database/migrations/2026_09_28_090000_version_investment_dossiers.php b/database/migrations/2026_09_28_090000_version_investment_dossiers.php new file mode 100644 index 00000000..39cc44ed --- /dev/null +++ b/database/migrations/2026_09_28_090000_version_investment_dossiers.php @@ -0,0 +1,20 @@ +unsignedInteger('version_no')->default(1); + }); + } + + public function down(): void + { + throw new RuntimeException('Preserve editorial versions; restore a verified backup instead.'); + } +}; diff --git a/database/migrations/2026_09_28_100000_investment_zone_foundation.php b/database/migrations/2026_09_28_100000_investment_zone_foundation.php new file mode 100644 index 00000000..f27f4d70 --- /dev/null +++ b/database/migrations/2026_09_28_100000_investment_zone_foundation.php @@ -0,0 +1,159 @@ +uuid('id')->primary(); + $t->string('title'); + $t->text('url'); + $t->string('diploma'); + $t->string('article'); + $t->string('competent_entity'); + $t->date('valid_from'); + $t->date('valid_until')->nullable(); + $t->string('validation_status')->default('pending'); + $t->foreignId('owner_id')->constrained('users')->restrictOnDelete(); + $t->foreignId('validator_id')->nullable()->constrained('users')->restrictOnDelete(); + $t->timestampTz('validated_at')->nullable(); + $t->unsignedInteger('version_no')->default(1); + $t->timestampsTz(); + }); + DB::statement("ALTER TABLE investment_legal_sources ADD CHECK(validation_status IN ('pending','validated','revoked')), ADD CHECK(valid_until IS NULL OR valid_until>=valid_from), ADD CHECK(version_no>0), ADD CHECK(validation_status<>'validated' OR (validator_id IS NOT NULL AND validator_id<>owner_id AND validated_at IS NOT NULL))"); + Schema::create('investment_zones', function (Blueprint $t) { + $t->uuid('id')->primary(); + $t->string('code')->unique(); + $t->string('category'); + $t->string('name'); + $t->string('name_en')->nullable(); + $t->text('description')->nullable(); + $t->text('description_en')->nullable(); + $t->string('province')->nullable(); + $t->string('district')->nullable(); + $t->foreignUuid('operator_id')->nullable()->constrained('organizations')->restrictOnDelete(); + $t->foreignUuid('legal_source_id')->nullable()->constrained('investment_legal_sources')->restrictOnDelete(); + $t->string('publication_status')->default('draft'); + $t->string('validation_status')->default('pending'); + $t->foreignId('editor_id')->constrained('users')->restrictOnDelete(); + $t->foreignId('reviewer_id')->nullable()->constrained('users')->restrictOnDelete(); + $t->timestampTz('published_at')->nullable(); + $t->unsignedInteger('version_no')->default(1); + $t->timestampsTz(); + $t->softDeletesTz(); + }); + DB::statement("ALTER TABLE investment_zones ADD CHECK(category IN ('zee','zfi','industrial_park','pending')), ADD CHECK(publication_status IN ('draft','published','suspended')), ADD CHECK(validation_status IN ('pending','validated','historical')), ADD CHECK(version_no>0), ADD CHECK(publication_status<>'published' OR (validation_status='validated' AND category<>'pending' AND legal_source_id IS NOT NULL AND operator_id IS NOT NULL AND reviewer_id IS NOT NULL AND reviewer_id<>editor_id AND published_at IS NOT NULL AND name_en IS NOT NULL AND description_en IS NOT NULL))"); + Schema::create('investment_items', function (Blueprint $t) { + $t->uuid('id')->primary(); + $t->string('code')->unique(); + $t->string('title'); + $t->string('kind'); + $t->string('validation_status')->default('pending'); + $t->unsignedInteger('version_no')->default(1); + $t->timestampsTz(); + }); + foreach (['opportunities' => ['opportunity_id', false], 'projects' => ['project_id', true]] as $table => [$foreign, $uuid]) { + Schema::create('investment_item_'.$table, function (Blueprint $t) use ($table, $foreign, $uuid) { + $t->foreignUuid('item_id')->constrained('investment_items')->restrictOnDelete(); + if ($uuid) { + $t->foreignUuid($foreign)->constrained($table)->restrictOnDelete(); + } else { + $t->foreignId($foreign)->constrained($table)->restrictOnDelete(); + } + $t->primary(['item_id', $foreign]); + $t->unique($foreign); + $t->text('mapping_evidence'); + $t->timestampsTz(); + }); + } + // Explicit nullable FKs: exactly one target, no polymorphic unverified identifiers. + Schema::create('investment_zone_relations', function (Blueprint $t) { + $t->uuid('id')->primary(); + $t->foreignUuid('zone_id')->constrained('investment_zones')->restrictOnDelete(); + $t->foreignUuid('cluster_id')->nullable()->constrained('economic_clusters')->restrictOnDelete(); + $t->foreignUuid('corridor_id')->nullable()->constrained('development_corridors')->restrictOnDelete(); + $t->foreignUuid('project_id')->nullable()->constrained('projects')->restrictOnDelete(); + $this->relationFields($t); + }); + DB::statement('ALTER TABLE investment_zone_relations ADD CHECK(num_nonnulls(cluster_id,corridor_id,project_id)=1)'); + foreach (['cluster_id', 'corridor_id', 'project_id'] as $key) { + DB::statement("CREATE UNIQUE INDEX zone_relation_{$key}_unique ON investment_zone_relations(zone_id,{$key}) WHERE {$key} IS NOT NULL"); + } + Schema::create('investment_zone_links', function (Blueprint $t) { + $t->uuid('id')->primary(); + $t->foreignUuid('item_id')->constrained('investment_items')->restrictOnDelete(); + $t->foreignUuid('zone_id')->constrained('investment_zones')->restrictOnDelete(); + $this->relationFields($t); + $t->unique(['item_id', 'zone_id']); + }); + foreach (['investment_zone_links', 'investment_zone_relations'] as $table) { + DB::statement("ALTER TABLE {$table} ADD CHECK(association_type IN ('official','influence','proximity','potential','historical')), ADD CHECK(validation_status IN ('pending','validated','revoked')), ADD CHECK(valid_until IS NULL OR valid_until>=valid_from), ADD CHECK(length(trim(source_reference))>=10), ADD CHECK(version_no>0), ADD CHECK(association_type<>'official' OR legal_source_id IS NOT NULL), ADD CHECK(validation_status<>'validated' OR (validator_id IS NOT NULL AND validator_id<>editor_id))"); + } + Schema::create('investment_zone_geometries', function (Blueprint $t) { + $t->uuid('id')->primary(); + $t->foreignUuid('zone_id')->constrained('investment_zones')->restrictOnDelete(); + $t->foreignUuid('geometry_id')->constrained('geo_geometries')->restrictOnDelete(); + $t->foreignUuid('legal_source_id')->nullable()->constrained('investment_legal_sources')->restrictOnDelete(); + $t->string('purpose'); + $t->text('source_reference'); + $t->string('validating_entity'); + $t->date('observed_at'); + $t->boolean('publication_authorized')->default(false); + $t->string('validation_status')->default('pending'); + $t->unsignedInteger('version_no')->default(1); + $t->timestampsTz(); + $t->unique(['zone_id', 'geometry_id']); + }); + DB::statement("ALTER TABLE investment_zone_geometries ADD CHECK(purpose IN ('official_perimeter','influence','site','alternative')), ADD CHECK(validation_status IN ('pending','validated','revoked')), ADD CHECK(purpose<>'official_perimeter' OR legal_source_id IS NOT NULL), ADD CHECK(NOT publication_authorized OR validation_status='validated')"); + Schema::create('investment_zone_revisions', function (Blueprint $t) { + $t->uuid('id')->primary(); + $t->foreignUuid('zone_id')->constrained('investment_zones')->restrictOnDelete(); + $t->unsignedInteger('version_no'); + $t->foreignId('actor_id')->constrained('users')->restrictOnDelete(); + $t->string('action'); + $t->text('snapshot'); + $t->char('sha256', 64); + $t->timestampTz('created_at')->useCurrent(); + $t->unique(['zone_id', 'version_no']); + }); + DB::unprepared("CREATE OR REPLACE FUNCTION reject_zone_revision_change() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Zone revisions are immutable'; END $$; CREATE TRIGGER immutable_zone_revision BEFORE UPDATE OR DELETE ON investment_zone_revisions FOR EACH ROW EXECUTE FUNCTION reject_zone_revision_change()"); + } + + private function relationFields(Blueprint $t): void + { + $t->string('role')->default('related'); + $t->string('association_type')->default('potential'); + $t->boolean('is_primary')->default(false); + $t->integer('priority')->default(0); + $t->text('justification'); + $t->text('source_reference'); + $t->foreignUuid('legal_source_id')->nullable()->constrained('investment_legal_sources')->restrictOnDelete(); + $t->date('valid_from'); + $t->date('valid_until')->nullable(); + $t->string('validation_status')->default('pending'); + $t->foreignId('editor_id')->constrained('users')->restrictOnDelete(); + $t->foreignId('validator_id')->nullable()->constrained('users')->restrictOnDelete(); + $t->unsignedInteger('version_no')->default(1); + $t->timestampsTz(); + } + + public function down(): void + { + foreach (self::TABLES as $table) { + if (DB::table($table)->exists()) { + throw new RuntimeException('Zone data exists. Restore a verified backup; do not discard records.'); + } + } + foreach (self::TABLES as $table) { + Schema::drop($table); + } + DB::statement('DROP FUNCTION reject_zone_revision_change()'); + } +}; diff --git a/database/migrations/2026_09_28_110000_zone_geometry_review.php b/database/migrations/2026_09_28_110000_zone_geometry_review.php new file mode 100644 index 00000000..9fb78b6a --- /dev/null +++ b/database/migrations/2026_09_28_110000_zone_geometry_review.php @@ -0,0 +1,30 @@ +foreignId('editor_id')->nullable()->constrained('users')->restrictOnDelete(); + $t->foreignId('reviewer_id')->nullable()->constrained('users')->restrictOnDelete(); + }); + DB::statement('ALTER TABLE investment_zone_geometries ADD CONSTRAINT zone_geometry_independent_review CHECK(NOT publication_authorized OR (editor_id IS NOT NULL AND reviewer_id IS NOT NULL AND editor_id<>reviewer_id))'); + } + + public function down(): void + { + if (DB::table('investment_zone_geometries')->exists()) { + throw new RuntimeException('Preserve GIS reviews; restore a verified backup.'); + } + DB::statement('ALTER TABLE investment_zone_geometries DROP CONSTRAINT zone_geometry_independent_review'); + Schema::table('investment_zone_geometries', function (Blueprint $t) { + $t->dropConstrainedForeignId('editor_id'); + $t->dropConstrainedForeignId('reviewer_id'); + }); + } +}; diff --git a/database/migrations/2026_09_28_120000_investment_submission_zones.php b/database/migrations/2026_09_28_120000_investment_submission_zones.php new file mode 100644 index 00000000..5a121faf --- /dev/null +++ b/database/migrations/2026_09_28_120000_investment_submission_zones.php @@ -0,0 +1,30 @@ +foreignUuid('submission_id')->primary()->constrained('investment_submissions')->restrictOnDelete(); + $t->foreignUuid('zone_id')->constrained('investment_zones')->restrictOnDelete(); + $t->unsignedInteger('zone_version'); + $t->string('intent'); + $t->timestampTz('created_at')->useCurrent(); + $t->index(['zone_id', 'intent']); + }); + DB::statement("ALTER TABLE investment_submission_zones ADD CHECK(zone_version>0), ADD CHECK(intent IN ('interest','dossier','meeting'))"); + } + + public function down(): void + { + if (DB::table('investment_submission_zones')->exists()) { + throw new RuntimeException('Preserve submission origins; restore a verified backup.'); + } + Schema::drop('investment_submission_zones'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 00000000..47760fb4 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,37 @@ +environment(['local', 'testing'])) { + return; + } + + $this->call(InvestMozReferenceSeeder::class); + $this->call(InvestmentReferenceSeeder::class); + + // User::factory(10)->create(); + + User::firstOrCreate( + ['email' => 'test@example.com'], + [ + 'name' => 'Test User', + 'password' => Hash::make(Str::random(48)), + ], + ); + } +} diff --git a/database/seeders/InvestMozReferenceSeeder.php b/database/seeders/InvestMozReferenceSeeder.php new file mode 100644 index 00000000..d010f54b --- /dev/null +++ b/database/seeders/InvestMozReferenceSeeder.php @@ -0,0 +1,40 @@ +environment(['local', 'testing'])) { + throw new \RuntimeException('Reference fixtures are restricted to local/testing.'); + } + + Project::withTrashed()->updateOrCreate( + ['source_system' => 'example', 'external_id' => 'PROJECT-001'], + [ + 'code' => 'EXAMPLE-PROJECT-001', + 'name' => 'Projecto fictício para validação técnica', + 'status' => 'draft', + 'budget_amount' => '1000.00', + 'budget_currency' => 'MZN', + 'data_classification' => 'internal', + 'deleted_at' => null, + ], + ); + Investor::withTrashed()->updateOrCreate( + ['source_system' => 'example', 'external_id' => 'INVESTOR-001'], + [ + 'legal_name' => 'Entidade fictícia para validação técnica', + 'investor_type' => 'institutional', + 'status' => 'prospect', + 'data_classification' => 'internal', + 'deleted_at' => null, + ], + ); + } +} diff --git a/database/seeders/InvestmentPortfolioSeeder.php b/database/seeders/InvestmentPortfolioSeeder.php new file mode 100644 index 00000000..7d1b04b3 --- /dev/null +++ b/database/seeders/InvestmentPortfolioSeeder.php @@ -0,0 +1,66 @@ + [Project::class, 'project_id', 'draft', '6.1 / 6.6'], + 'clusters' => [EconomicCluster::class, 'cluster_id', 'planned', '4.5.2'], + 'corridors' => [DevelopmentCorridor::class, 'corridor_id', 'proposed', '4.3.4']] as $kind => [$model, $foreign, $status, $section]) { + foreach ($data[$kind] as $item) { + // Never overwrite a manually edited record or silently take over its code. + $record = $model::withTrashed()->where('code', $item['code'])->first(); + if ($record && ($record->getAttribute('source_system') !== 'upgrade-v4' || $record->trashed())) { + throw new \RuntimeException('Portfolio code collision: '.$item['code']); + } + $record ??= $model::create(['code' => $item['code'], 'name' => $item['name'], 'status' => $status, + 'source_system' => 'upgrade-v4', 'external_id' => $item['code'], + ...($kind === 'clusters' ? ['sector' => $item['name']] : ['description' => $item['description'] ?? $item['focus']])]); + $ids[$item['code']] = $record->id; + InvestmentDossier::withTrashed()->firstOrCreate([$foreign => $record->id], [ + 'title' => $item['name'], 'source' => $data['source'].' — '.$section, + 'content' => [...$item, 'notice' => $data['notice']], 'publication_status' => 'draft', + ]); + } + } + foreach (['clusters' => ['cluster_projects', 'cluster_id'], 'corridors' => ['corridor_projects', 'corridor_id']] as $kind => [$table, $foreign]) { + foreach ($data[$kind] as $item) { + preg_match_all('/\bA\d{2}\b/', $item['anchors'], $matches); + foreach (array_unique($matches[0]) as $code) { + $key = [$foreign => $ids[$item['code']], 'project_id' => $ids[$code]]; + if (! DB::table($table)->where($key)->exists()) { + DB::table($table)->insert([...$key, 'id' => (string) Str::uuid(), 'source_system' => 'upgrade-v4', + ...($kind === 'clusters' ? ['role' => 'anchor'] : [])]); + } + } + } + } + // Only unambiguous correspondences in section 4.3.4; no inferred mining anchors. + $matrix = ['COR-01' => [1, 2, 3, 4, 6], 'COR-02' => [1, 2, 3, 5], 'COR-03' => [1, 2, 3, 6], + 'COR-04' => [1, 3], 'COR-05' => [4, 7], 'COR-06' => [2, 3, 7]]; + foreach ($matrix as $corridor => $clusters) { + foreach ($clusters as $cluster) { + $key = ['corridor_id' => $ids[$corridor], 'cluster_id' => $ids[sprintf('CLU-%02d', $cluster)]]; + if (! DB::table('corridor_clusters')->where($key)->exists()) { + DB::table('corridor_clusters')->insert([...$key, 'id' => (string) Str::uuid(), 'source_system' => 'upgrade-v4']); + } + } + } + }); + } +} diff --git a/database/seeders/InvestmentReferenceSeeder.php b/database/seeders/InvestmentReferenceSeeder.php new file mode 100644 index 00000000..380dd79b --- /dev/null +++ b/database/seeders/InvestmentReferenceSeeder.php @@ -0,0 +1,23 @@ + $options) { + foreach ([...$options, 'other' => 'Outro — especificar'] as $code => $label) { + DB::table('investment_reference_options')->insertOrIgnore(['group' => $group, 'code' => $code, 'label' => $label, + 'active' => true, 'requires_description' => $code === 'other', 'created_at' => now(), 'updated_at' => now()]); + } + } + }); + } +} diff --git a/database/seeders/OpportunitySeeder.php b/database/seeders/OpportunitySeeder.php new file mode 100644 index 00000000..75a84b8c --- /dev/null +++ b/database/seeders/OpportunitySeeder.php @@ -0,0 +1,63 @@ + */ + private array $maturityLabels = [ + 1 => 'Ideia identificada', + 2 => 'Pré-estudo disponível', + 3 => 'Estudo de viabilidade em curso', + 4 => 'Projecto estruturado', + 5 => 'Pronto para investimento', + 6 => 'Em negociação ou implementação', + ]; + + public function run(): void + { + $items = [ + ['sector' => 'agronegocio', 'reference' => 'INV-MZ-AGR-001', 'title' => 'Unidade de processamento agrícola em Nampula', 'province' => 'Nampula', 'maturity' => 4, 'amount' => 2500000, 'financing' => 'Capital e parceria técnica', 'impact' => '120 empregos directos e substituição de importações.', 'featured' => true], + ['sector' => 'energia', 'reference' => 'INV-MZ-ENE-002', 'title' => 'Mini-rede solar para polo produtivo', 'province' => 'Zambézia', 'maturity' => 3, 'amount' => 1200000, 'financing' => 'PPP ou financiamento misto', 'impact' => 'Acesso a energia para 40 unidades produtivas.', 'featured' => true], + ['sector' => 'turismo', 'reference' => 'INV-MZ-TUR-003', 'title' => 'Eco-lodge costeiro com integração comunitária', 'province' => 'Inhambane', 'maturity' => 2, 'amount' => 1800000, 'financing' => 'Capital privado ou joint venture', 'impact' => 'Emprego local e conservação da faixa costeira.', 'featured' => true], + ['sector' => 'infraestruturas', 'reference' => 'INV-MZ-INF-004', 'title' => 'Centro logístico provincial', 'province' => 'Sofala', 'maturity' => 4, 'amount' => 8500000, 'financing' => 'Concessão ou PPP', 'impact' => 'Redução de custos logísticos regionais.', 'featured' => false], + ['sector' => 'economia-azul', 'reference' => 'INV-MZ-BLUE-005', 'title' => 'Processamento de pescado e cadeia fria', 'province' => 'Zambézia', 'maturity' => 2, 'amount' => 900000, 'financing' => 'Capital, crédito ou parceria operacional', 'impact' => 'Redução de perdas pós-captura na pesca artesanal.', 'featured' => false], + ['sector' => 'mercado-financeiro', 'reference' => 'INV-MZ-FIN-006', 'title' => 'Plataforma de crédito para PME agrícolas', 'province' => 'Maputo Cidade', 'maturity' => 3, 'amount' => 3000000, 'financing' => 'Fundo de investimento ou parceiro financeiro', 'impact' => 'Acesso a financiamento para 300+ pequenas empresas.', 'featured' => false], + ]; + + foreach ($items as $item) { + $sector = Sector::where('slug', $item['sector'])->first(); + if (! $sector) { + continue; + } + + Opportunity::updateOrCreate( + ['reference' => $item['reference']], + [ + 'sector_id' => $sector->id, + 'title' => $item['title'], + 'slug' => Str::slug($item['reference'].'-'.$item['title']), + 'summary' => $item['impact'], + 'description' => $item['impact'], + 'province' => $item['province'], + 'promoter_name' => 'Promotor em validação', + 'promoter_type' => 'privado', + 'maturity_level' => $item['maturity'], + 'maturity_label' => $this->maturityLabels[$item['maturity']], + 'estimated_investment_amount' => $item['amount'], + 'estimated_investment_currency' => 'USD', + 'financing_modality' => $item['financing'], + 'expected_impact' => $item['impact'], + 'status' => 'published', + 'is_featured' => $item['featured'], + 'published_at' => now(), + ] + ); + } + } +} diff --git a/database/seeders/SectorSeeder.php b/database/seeders/SectorSeeder.php new file mode 100644 index 00000000..e9e30341 --- /dev/null +++ b/database/seeders/SectorSeeder.php @@ -0,0 +1,28 @@ + 'Agronegócio', 'slug' => 'agronegocio', 'short_description' => 'Produção, agroprocessamento, irrigação e cadeias de valor agrícolas.'], + ['name' => 'Energia e Mineração', 'slug' => 'energia', 'short_description' => 'Energias renováveis, transição energética e mineração responsável.'], + ['name' => 'Turismo', 'slug' => 'turismo', 'short_description' => 'Hotelaria, turismo costeiro, cultural e ecológico.'], + ['name' => 'Infraestruturas', 'slug' => 'infraestruturas', 'short_description' => 'Logística, transportes, parques industriais e energia.'], + ['name' => 'Economia Azul', 'slug' => 'economia-azul', 'short_description' => 'Pesca, aquacultura, portos e logística marítima.'], + ['name' => 'Mercado Financeiro', 'slug' => 'mercado-financeiro', 'short_description' => 'Fintech, capital de risco e instrumentos financeiros.'], + ]; + + foreach ($sectors as $index => $sector) { + Sector::updateOrCreate( + ['slug' => $sector['slug']], + [...$sector, 'is_active' => true, 'sort_order' => $index + 1, 'published_at' => now()] + ); + } + } +} diff --git a/deploy/linux/README.md b/deploy/linux/README.md new file mode 100644 index 00000000..f2f7ff17 --- /dev/null +++ b/deploy/linux/README.md @@ -0,0 +1,96 @@ +# investemoz.tempo.co.mz — instalação Linux + +Modelos para Linux com systemd, Nginx e PHP-FPM. Caminho assumido: +`/var/www/investemoz`; utilizador web `www-data`; PHP 8.5. Confirmar distribuição, +binário e socket antes de aplicar (não se assume que os pacotes estejam instalados). +Nenhum destes ficheiros foi aplicado remotamente. O `.env` local fica intacto. + +## 1. Preparar o servidor + +Instalar Nginx, PHP CLI/FPM compatível com composer.lock e extensões exigidas por +`composer check-platform-reqs --no-dev`, incluindo pdo_pgsql, mbstring, xml, curl +e intl. Instalar PostgreSQL/PostGIS ou provisionar uma instância externa. +Usar o gestor de pacotes e versões suportados pela distribuição escolhida. +Verificar `php -v`, `php -m`, `nginx -v` e o socket em `/run/php/`. + +Apontar o registo DNS A de investemoz.tempo.co.mz para o IP do servidor. Publicar +AAAA apenas se IPv6 estiver configurado (os modelos fornecidos escutam IPv4). +Permitir HTTPS/HTTP e o acesso administrativo necessário no firewall. Não expor +PostgreSQL publicamente por defeito. + +Publicar o projecto em `/var/www/investemoz`, excluindo .env local, public/hot, +node_modules e dados locais de storage. Preservar storage nas actualizações. +O código pertence ao utilizador de implantação; www-data só necessita de escrita +em storage e bootstrap/cache. O .env deve ser legível pelo PHP, sem acesso público +nem permissões globais (por exemplo proprietário de implantação, grupo www-data, 0640). + +## 2. Aplicação e base de dados + +Usar production.env.example como ponto de partida apenas numa instalação nova. +Preencher os segredos no servidor e criar base/utilizadores com privilégios revistos. +Provisionar PostGIS com a conta administrativa. Seguir +[o guia de publicação](../../docs/investmoz/deployment.md) para dependências, +build, APP_KEY, migrations e backups. Não sobrescrever APP_KEY nem .env numa +actualização. Manter configurações de migração e execução separadas. + +O build precisa de PHP para gerar as rotas Wayfinder. Não copiar vendor gerado +para Windows sem reinstalar/verificar dependências na plataforma de destino. +Não activar notificações até validar o correio. MAIL_MAILER=log é provisório: +não entrega verificações de conta; os logs podem conter ligações sensíveis. + +## 3. Certificado e Nginx + +Instalar Certbot pelo método indicado para a distribuição. Criar +`/var/www/letsencrypt` e instalar nginx-http.conf como o virtual host deste domínio. +Em Debian/Ubuntu pode usar sites-available/sites-enabled; noutras distribuições +usar o directório incluído pelo nginx.conf. Não activar ambos os modelos ao mesmo tempo. + +```sh +sudo nginx -t +sudo systemctl reload nginx +sudo certbot certonly --webroot -w /var/www/letsencrypt -d investemoz.tempo.co.mz +``` + +Depois de existir o certificado, substituir o virtual host pelo nginx-https.conf, +ajustando o socket fastcgi_pass. Validar com nginx -t antes de recarregar. Confirmar +o mecanismo de renovação do Certbot e configurar um deploy hook que execute +`systemctl reload nginx` após cada renovação bem sucedida. Testar renovação com +`sudo certbot renew --dry-run`. Não activar o modelo HTTPS antes do certificado. + +## 4. Fila + +Rever utilizador, caminhos e PHP em investemoz-queue.service. Instalar a unidade +em `/etc/systemd/system/investemoz-queue.service` e executar: + +```sh +sudo systemd-analyze verify /etc/systemd/system/investemoz-queue.service +sudo systemctl daemon-reload +sudo systemctl enable --now investemoz-queue +sudo systemctl status investemoz-queue +sudo journalctl -u investemoz-queue -n 50 +``` + +O serviço lê o .env pelo Laravel, não por EnvironmentFile. Recarregar config e +executar `php artisan queue:restart` após alterações. Não executar outro supervisor +para este mesmo worker. Configurar rotação de logs Laravel e monitorização de jobs +falhados e espaço em disco; a aplicação não remove o histórico de auditoria. + +## 5. Verificação final + +```sh +cd /var/www/investemoz +php artisan config:cache +php artisan route:cache +php artisan view:cache +php artisan portal:readiness +curl --fail https://investemoz.tempo.co.mz/up +curl --fail https://investemoz.tempo.co.mz/api/v1/home +``` + +Confirmar redireccionamento HTTP, certificado, página inicial, ficheiro estático, +ligação directa de oportunidade e login. `/acesso-gestao` permite configurar a +conta mais tarde. Readiness continuará a indicar correio pendente enquanto usar log. +Testar restauração de backup numa instância separada antes de colocar dados reais. + +Referências: [Nginx try_files](https://nginx.org/en/docs/http/ngx_http_core_module.html#try_files), +[systemd.service](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html). diff --git a/deploy/linux/investemoz-queue.service b/deploy/linux/investemoz-queue.service new file mode 100644 index 00000000..5b185fe8 --- /dev/null +++ b/deploy/linux/investemoz-queue.service @@ -0,0 +1,26 @@ +[Unit] +Description=InvestMoz Laravel database queue +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/var/www/investemoz +ExecStart=/usr/bin/php8.5 /var/www/investemoz/artisan queue:work database --queue=default --sleep=3 --tries=3 --timeout=30 --max-time=3600 +Restart=always +RestartSec=5 +TimeoutStopSec=60 +KillSignal=SIGTERM +UMask=0027 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/www/investemoz/storage /var/www/investemoz/bootstrap/cache +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/deploy/linux/nginx-http.conf b/deploy/linux/nginx-http.conf new file mode 100644 index 00000000..e0134d76 --- /dev/null +++ b/deploy/linux/nginx-http.conf @@ -0,0 +1,10 @@ +# Bootstrap only: obtain the certificate before enabling nginx-https.conf. +server { + listen 80; + server_name investemoz.tempo.co.mz; + location ^~ /.well-known/acme-challenge/ { + root /var/www/letsencrypt; + try_files $uri =404; + } + location / { return 503; } +} diff --git a/deploy/linux/nginx-https.conf b/deploy/linux/nginx-https.conf new file mode 100644 index 00000000..5b435af2 --- /dev/null +++ b/deploy/linux/nginx-https.conf @@ -0,0 +1,41 @@ +server { + listen 80; + server_name investemoz.tempo.co.mz; + location ^~ /.well-known/acme-challenge/ { + root /var/www/letsencrypt; + try_files $uri =404; + } + location / { return 301 https://investemoz.tempo.co.mz$request_uri; } +} + +server { + listen 443 ssl; + server_name investemoz.tempo.co.mz; + root /var/www/investemoz/public; + index index.php; + charset utf-8; + server_tokens off; + client_max_body_size 10m; + + ssl_certificate /etc/letsencrypt/live/investemoz.tempo.co.mz/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/investemoz.tempo.co.mz/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + + location / { try_files $uri $uri/ /index.php?$query_string; } + location = /index.php { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $realpath_root/index.php; + fastcgi_param DOCUMENT_ROOT $realpath_root; + fastcgi_param HTTPS on; + # Match the installed PHP-FPM socket before enabling. + fastcgi_pass unix:/run/php/php8.5-fpm.sock; + } + location ~ \.php(?:/|$) { return 404; } + location ~ /\. { deny all; } + location = /hot { return 404; } + location /build/ { try_files $uri =404; } +} diff --git a/deploy/linux/production.env.example b/deploy/linux/production.env.example new file mode 100644 index 00000000..c1aa1ae6 --- /dev/null +++ b/deploy/linux/production.env.example @@ -0,0 +1,35 @@ +APP_NAME="Investe Moçambique" +APP_ENV=production +APP_KEY= +APP_DEBUG=false +APP_URL=https://investemoz.tempo.co.mz +APP_LOCALE=pt +LOG_CHANNEL=stack +LOG_STACK=single +LOG_LEVEL=warning +DB_CONNECTION=pgsql +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_DATABASE=investemoz +DB_USERNAME=investemoz_app +DB_PASSWORD= +SESSION_DRIVER=database +SESSION_SECURE_COOKIE=true +SESSION_HTTP_ONLY=true +SESSION_SAME_SITE=lax +SESSION_DOMAIN=null +CACHE_STORE=database +QUEUE_CONNECTION=database +DB_QUEUE_RETRY_AFTER=90 +PORTAL_MANAGER_EMAILS=comercial@tempo.co.mz +PORTAL_NOTIFICATIONS_ENABLED=false +PORTAL_NOTIFICATION_RECIPIENTS= +MAIL_MAILER=log +MAIL_HOST= +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_TIMEOUT=15 +MAIL_FROM_ADDRESS=comercial@tempo.co.mz +MAIL_FROM_NAME="Investe Moçambique" +VITE_APP_NAME="Investe Moçambique" diff --git a/deploy/suitecrm/.dockerignore b/deploy/suitecrm/.dockerignore new file mode 100644 index 00000000..e8d68625 --- /dev/null +++ b/deploy/suitecrm/.dockerignore @@ -0,0 +1,4 @@ +* +!Dockerfile +!apache.conf +!php.ini diff --git a/deploy/suitecrm/.gitignore b/deploy/suitecrm/.gitignore new file mode 100644 index 00000000..09e6339b --- /dev/null +++ b/deploy/suitecrm/.gitignore @@ -0,0 +1,3 @@ +.secrets/ +.env +backups/ diff --git a/deploy/suitecrm/Dockerfile b/deploy/suitecrm/Dockerfile new file mode 100644 index 00000000..46b5a3a5 --- /dev/null +++ b/deploy/suitecrm/Dockerfile @@ -0,0 +1,24 @@ +FROM php:8.4-apache-bookworm@sha256:57ccfbd765e5ee5f85dee5864e47ca8db0b540b0c091c052d9b275b2498a5395 + +# Homologation image. Official prebuilt release, verified before extraction. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl unzip libicu-dev libzip-dev libpng-dev libjpeg62-turbo-dev \ + libfreetype6-dev libonig-dev libxml2-dev libcurl4-openssl-dev \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j2 gd intl zip mbstring mysqli pdo_mysql soap curl opcache \ + && a2enmod rewrite headers \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /var/www/html +RUN curl --fail --location --proto '=https' --tlsv1.2 \ + 'https://github.com/SuiteCRM/SuiteCRM-Core/releases/download/v8.10.2/SuiteCRM-8.10.2.zip' \ + --output /tmp/suitecrm.zip \ + && echo 'ac4693d10ea2d6cd20b3256098781d134599d88878fb24e8dc127038d624f5a8 /tmp/suitecrm.zip' | sha256sum -c - \ + && unzip -q /tmp/suitecrm.zip -d /var/www/html \ + && test -f bin/console && test -f public/index.php \ + && chmod +x bin/console \ + && chown -R www-data:www-data /var/www/html \ + && rm /tmp/suitecrm.zip + +COPY apache.conf /etc/apache2/sites-available/000-default.conf +COPY php.ini /usr/local/etc/php/conf.d/investmoz-suitecrm.ini diff --git a/deploy/suitecrm/LOCAL.md b/deploy/suitecrm/LOCAL.md new file mode 100644 index 00000000..c6870bdf --- /dev/null +++ b/deploy/suitecrm/LOCAL.md @@ -0,0 +1,33 @@ +# Instalação local e bloqueio do anfitrião + +Diagnóstico de 25/09/2026: Windows ARM64 em **Parallels ARM Virtual Machine**, sem Docker, WSL não instalado, sessão sem privilégios administrativos e virtualização não exposta. `wsl --install --no-distribution` falhou. Não foi instalado SuiteCRM nem criado utilizador/OAuth. A [documentação Parallels](https://kb.parallels.com/en/129497) identifica a limitação de virtualização aninhada/WSL2 neste cenário. + +## Caminho recomendado nesta máquina: Docker no macOS anfitrião + +1. Instalar e abrir [Docker Desktop para Mac](https://docs.docker.com/desktop/setup/install/mac-install/) no macOS. Este terminal Windows não tem acesso ao motor ou terminal do macOS. +2. Copiar a pasta `deploy/suitecrm` para uma pasta local do Mac. Não copiar `.secrets` de outra instalação. Não executar a aplicação directamente numa pasta partilhada: o Compose já usa volumes nomeados para base e aplicação. +3. No Terminal do Mac, dentro da pasta copiada, executar `bash start-local.sh`. +4. Concluir o instalador interactivo com `docker compose -f compose.json exec --user www-data web php bin/console suitecrm:app:install`, seguindo o README. A password da base encontra-se em `.secrets/db_password`; introduzi-la apenas no instalador, sem publicar no chat. Definir uma conta administradora própria; a conta comercial pode continuar por configurar. + +O script gera passwords apenas se ausentes, restringe permissões e preserva volumes. Build ARM, instalação e arranque ainda não foram testados neste anfitrião. O endereço loopback pertence ao Mac, não ao Windows convidado. A ligação Laravel no Windows requer posteriormente um endereço HTTPS acessível pela VM, certificado confiável e proxy configurado; não activar a integração apontando para HTTP ou desactivando TLS. + +## Windows com virtualização suportada + +Depois de Docker Desktop e motor Linux estarem disponíveis, executar a partir da raiz do projecto: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File deploy/suitecrm/start-local.ps1 -CheckOnly +powershell -NoProfile -ExecutionPolicy Bypass -File deploy/suitecrm/start-local.ps1 +``` + +O script não instala funcionalidades do Windows nem reinicia o computador. Não resolve a limitação de virtualização do Parallels ARM. + +## Configuração e afinação após instalação + +- Concluir URL HTTPS, proxy, cookies seguros, utilizador técnico e OAuth conforme README. Guardar credenciais apenas no backend. +- Executar `php artisan suitecrm:probe` depois de configurar `SUITECRM_ENABLED`, `SUITECRM_BASE_URL` (incluindo `/legacy` quando aplicável), `SUITECRM_CLIENT_ID` e `SUITECRM_CLIENT_SECRET`. O comando autentica e consulta metadados de Contacts; não escreve registos nem mostra tokens. Código de saída não zero indica falha. +- Configurar Messenger e Schedulers conforme README. Começar com um worker; medir duração, memória e backlog antes de aumentar concorrência. +- Manter limites iniciais de PHP em 512 MB, uploads de 20 MB e execução de 120 segundos. São valores de partida, não resultados de benchmark. Medir latência, CPU/RAM, consultas lentas e crescimento de volumes com dados fictícios antes de ajustar. +- Validar permissões com a conta técnica, campos de identificação externa, idempotência, falhas, reconciliação e restauro antes da migração comercial. O probe não substitui esses testes. + +O envio da outbox, migração comercial, TLS real, reconciliação, instalação de extensões e testes de carga permanecem pendentes. Este arranque não instala IA nem conclui os restantes requisitos funcionais do portal. diff --git a/deploy/suitecrm/README.md b/deploy/suitecrm/README.md new file mode 100644 index 00000000..2b485720 --- /dev/null +++ b/deploy/suitecrm/README.md @@ -0,0 +1,76 @@ +# SuiteCRM 8.10.2 — preparação de homologação + +Estado: modelo preparado; **build e arranque Docker ainda não executados**. Este Windows não tem Docker nem WSL instalado. Não aplicar em produção. O cliente Laravel e a captura na outbox existem; utilizador técnico, OAuth e envio real continuam pendentes. + +Para os scripts de arranque e o bloqueio identificado no Windows Parallels ARM, consultar [instalação local](LOCAL.md). Está disponível o diagnóstico `php artisan suitecrm:probe` para verificar OAuth e metadados após configurar um CRM acessível por HTTPS. + +A [decisão de arquitectura](../../docs/investmoz/suitecrm/architecture.md) define SuiteCRM como futuro sistema principal comercial. O portal continua operacional e não envia dados para este ambiente. + +## Dependências verificadas + +- Release oficial pré-compilada 8.10.2: download e SHA-256 conferidos; arquivo com `bin/console` e aplicação na raiz. +- PHP 8.4/Apache e MariaDB 11.4: combinação suportada na matriz oficial. Imagens fixadas pelos digests consultados no registry oficial em 25/09/2026. +- Motor Docker Linux e Compose v2 ainda necessários. JSON é usado como formato compatível com YAML para o ficheiro Compose. +- Dockerfile instala extensões do CRM; não altera PHP ou dependências Laravel. + +## Preparar e instalar numa máquina Linux de testes + +Executar a partir deste directório. Os comandos abaixo são instruções preparadas, não resultados de execução. + +1. Confirmar `docker version` e `docker compose version`. +2. Criar `.secrets` com acesso apenas ao operador. Criar os ficheiros `db_password` e `db_root_password` com valores aleatórios distintos, sem newline, usando um gestor de segredos. Não colocar segredos em argumentos de comandos, no Git ou no chat. O Compose local monta ficheiros; não constitui um cofre de segredos encriptado. +3. Validar a configuração e construir: + +```sh +docker compose -f compose.json config --quiet +docker compose -f compose.json build web +docker compose -f compose.json up -d db web +docker compose -f compose.json ps +docker compose -f compose.json exec --user www-data web php bin/console suitecrm:app:install +``` + +O instalador interactivo deve usar host `db`, base `suitecrm`, utilizador `suitecrm`, password definida no ficheiro privado e uma conta administradora exclusiva da homologação. Desactivar dados de demonstração se o ensaio de migração precisar de contagens exactas. Não usar a conta administradora como identidade da integração. + +O HTTP está exposto **apenas em 127.0.0.1:8088**, para bootstrap com dados fictícios. Para homologação partilhada/API, colocar proxy HTTPS diante desse listener, configurar o site URL definitivo, confiar apenas no proxy conhecido e activar cookies seguros. Não abrir a porta na rede nem transmitir credenciais/dados reais por HTTP. Não foram criados DNS ou certificados. + +Depois da instalação e da configuração do transporte: + +```sh +docker compose -f compose.json exec --user www-data web php bin/console messenger:setup-transports +docker compose -f compose.json --profile installed up -d worker +docker compose -f compose.json exec --user www-data web php bin/console schedulers:run +``` + +Agendar o último comando a cada minuto no host, sem TTY (`exec -T`), usando o caminho absoluto real para este compose e sem execuções sobrepostas. Validar o último processamento no painel Schedulers. O perfil `installed` impede iniciar o worker antes da configuração inicial. Não multiplicar workers antes dos testes de concorrência. + +## Conta técnica e OAuth — após arranque + +Criar utilizador técnico exclusivo sem administração, exportação geral ou acesso a módulos não usados. Configurar papéis/grupos e testar permissões com essa identidade. Criar cliente OAuth2 para client credentials associado à identidade técnica conforme as opções da instalação. Guardar o segredo num cofre acessível apenas ao backend/worker Laravel. Verificar obtenção, expiração, revogação e nova emissão de token. Não presumir refresh token neste grant. + +Confirmar o prefixo efectivo da API V8 nesta instalação SuiteCRM 8 e descobrir nomes de campos/relacionamentos pelos endpoints oficiais. Antes de ligar Laravel, comprovar criação, leitura, actualização e relação de registos fictícios, incluindo negação de operações fora do âmbito. + +## Persistência, logs e actualizações + +A base e a aplicação completa usam volumes nomeados para preservar configuração, chaves, extensões e uploads durante os ensaios. **Reconstruir a imagem não actualiza o código num volume existente.** Uma actualização requer procedimento oficial e backup compatível, ou nova instalação isolada; nunca apagar o volume para tentar actualizar uma instalação com dados. + +Não configurar SMTP, importações reais ou webhooks enquanto os controlos e o contrato não forem testados. O access log preparado não inclui URL/query, cabeçalhos ou corpo; rever também logs próprios do SuiteCRM e do PHP para dados sensíveis. Não activar debug em ambiente com dados pessoais. + +## Validação e rollback + +Verificações locais realizadas: existência da release/imagens, integridade SHA-256 e estrutura do ZIP, análise estática do Compose. Pendentes: build, extensões em runtime, instalação, autenticação, TLS, schedulers, worker, permissões e restauro. + +Para parar o ensaio preservando volumes: + +```sh +docker compose -f compose.json --profile installed stop +``` + +Antes de qualquer migração/upgrade, obter backup consistente da base, aplicação, uploads e segredos; ensaiar restauro em projecto Compose separado. Não executar `down -v`. A produção permanece NO-GO até testes funcionais, segurança, privacidade e recuperação. + +## Fontes + +- [Release oficial](https://github.com/SuiteCRM/SuiteCRM-Core/releases/tag/v8.10.2) +- [Matriz de compatibilidade](https://docs.suitecrm.com/8.x/admin/compatibility-matrix/) +- [Instalador CLI](https://docs.suitecrm.com/8.x/admin/installation-guide/running-the-cli-installer/) +- [Messenger](https://docs.suitecrm.com/8.x/admin/async-tasks/messenger-setup/) +- [Schedulers](https://docs.suitecrm.com/8.x/admin/administration-panel/schedulers/) diff --git a/deploy/suitecrm/apache.conf b/deploy/suitecrm/apache.conf new file mode 100644 index 00000000..8175a183 --- /dev/null +++ b/deploy/suitecrm/apache.conf @@ -0,0 +1,16 @@ +ServerName localhost +ServerTokens Prod +ServerSignature Off + + + DocumentRoot /var/www/html/public + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + # Do not include query strings, authentication headers or request bodies. + LogFormat "%a %t %m %>s %D" integration_minimal + CustomLog /proc/self/fd/1 integration_minimal + ErrorLog /proc/self/fd/2 + diff --git a/deploy/suitecrm/compose.json b/deploy/suitecrm/compose.json new file mode 100644 index 00000000..619dc2c4 --- /dev/null +++ b/deploy/suitecrm/compose.json @@ -0,0 +1,62 @@ +{ + "name": "investmoz-suitecrm-test", + "services": { + "db": { + "image": "mariadb:11.4@sha256:70cc072b29b4a89ae07abb2d4da2c64678a7f2dfe092751bb51c87d67dc1338b", + "restart": "unless-stopped", + "environment": { + "MARIADB_DATABASE": "suitecrm", + "MARIADB_USER": "suitecrm", + "MARIADB_PASSWORD_FILE": "/run/secrets/db_password", + "MARIADB_ROOT_PASSWORD_FILE": "/run/secrets/db_root_password" + }, + "secrets": ["db_password", "db_root_password"], + "volumes": ["crm_database:/var/lib/mysql"], + "networks": ["crm_database"], + "healthcheck": { + "test": [ + "CMD", + "healthcheck.sh", + "--connect", + "--innodb_initialized" + ], + "interval": "10s", + "timeout": "5s", + "retries": 10, + "start_period": "30s" + } + }, + "web": { + "build": { "context": "." }, + "image": "investmoz-suitecrm:8.10.2-test", + "restart": "unless-stopped", + "ports": ["127.0.0.1:8088:80"], + "volumes": ["crm_application:/var/www/html"], + "networks": ["crm_database", "crm_web"], + "depends_on": { "db": { "condition": "service_healthy" } } + }, + "worker": { + "image": "investmoz-suitecrm:8.10.2-test", + "profiles": ["installed"], + "user": "www-data", + "restart": "unless-stopped", + "command": [ + "php", + "bin/console", + "messenger:consume", + "internal-async", + "--time-limit=3600", + "--memory-limit=256M" + ], + "volumes": ["crm_application:/var/www/html"], + "networks": ["crm_database", "crm_web"], + "depends_on": { "db": { "condition": "service_healthy" } } + } + }, + "secrets": { + "db_password": { "file": "./.secrets/db_password" }, + "db_root_password": { "file": "./.secrets/db_root_password" } + }, + "volumes": { "crm_database": {}, "crm_application": {} }, + "networks": { "crm_database": { "internal": true }, "crm_web": {} } +} diff --git a/deploy/suitecrm/php.ini b/deploy/suitecrm/php.ini new file mode 100644 index 00000000..7ccfcc1f --- /dev/null +++ b/deploy/suitecrm/php.ini @@ -0,0 +1,10 @@ +memory_limit=512M +upload_max_filesize=20M +post_max_size=24M +max_execution_time=120 +display_errors=Off +log_errors=On +expose_php=Off +date.timezone=Africa/Maputo +session.cookie_httponly=1 +session.cookie_samesite=Lax diff --git a/deploy/suitecrm/start-local.ps1 b/deploy/suitecrm/start-local.ps1 new file mode 100644 index 00000000..a1d1eb20 --- /dev/null +++ b/deploy/suitecrm/start-local.ps1 @@ -0,0 +1,50 @@ +param([switch]$CheckOnly) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# This script never installs host software, restarts Windows or deletes volumes. +$docker = Get-Command docker -ErrorAction SilentlyContinue +if (-not $docker) { + $candidate = Join-Path $env:ProgramFiles 'Docker\Docker\resources\bin\docker.exe' + if (Test-Path -LiteralPath $candidate) { $docker = Get-Item -LiteralPath $candidate } +} +if (-not $docker) { + throw 'Docker ausente. Instale e inicie Docker Desktop com contentores Linux. Numa VM Parallels, confirme primeiro suporte a virtualizacao aninhada, ou execute o Compose no macOS anfitriao.' +} +$dockerPath = if ($docker -is [System.Management.Automation.CommandInfo]) { $docker.Source } else { $docker.FullName } +$engine = & $dockerPath info --format '{{.OSType}}' +if ($LASTEXITCODE -ne 0 -or $engine -ne 'linux') { throw 'Motor Docker Linux indisponivel.' } +& $dockerPath compose version +if ($LASTEXITCODE -ne 0) { throw 'Docker Compose v2 indisponivel.' } +if ($CheckOnly) { Write-Output 'Motor Linux e Compose disponiveis. Instalacao CRM ainda nao verificada.'; exit 0 } + +$secretDirectory = Join-Path $PSScriptRoot '.secrets' +if (Test-Path -LiteralPath $secretDirectory) { + if ((Get-Item -LiteralPath $secretDirectory).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'A pasta de segredos nao pode ser um link.' } +} else { New-Item -ItemType Directory -Path $secretDirectory | Out-Null } +$acl = New-Object Security.AccessControl.DirectorySecurity +$acl.SetAccessRuleProtection($true, $false) +$identity = [Security.Principal.WindowsIdentity]::GetCurrent().User +$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) +Set-Acl -LiteralPath $secretDirectory -AclObject $acl +foreach ($name in @('db_password', 'db_root_password')) { + $secretPath = Join-Path $secretDirectory $name + if (Test-Path -LiteralPath $secretPath) { + $item = Get-Item -LiteralPath $secretPath + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $item.Length -eq 0) { throw 'Ficheiro de segredo invalido.' } + continue + } + $bytes = New-Object byte[] 32 + $rng = [Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($bytes) } finally { $rng.Dispose() } + [IO.File]::WriteAllText($secretPath, [Convert]::ToBase64String($bytes), (New-Object Text.UTF8Encoding($false))) +} +$compose = Join-Path $PSScriptRoot 'compose.json' +& $dockerPath compose -f $compose config --quiet +if ($LASTEXITCODE -ne 0) { throw 'Configuracao Compose invalida.' } +& $dockerPath compose -f $compose build web +if ($LASTEXITCODE -ne 0) { throw 'Build falhou; volumes preservados.' } +& $dockerPath compose -f $compose up -d --wait --wait-timeout 180 db web +if ($LASTEXITCODE -ne 0) { throw 'Arranque falhou; consultar o estado dos contentores.' } +Write-Output 'Contentores iniciados em http://127.0.0.1:8088. Conclua o instalador SuiteCRM conforme README; OAuth e sincronizacao ainda nao activos.' diff --git a/deploy/suitecrm/start-local.sh b/deploy/suitecrm/start-local.sh new file mode 100644 index 00000000..80e3b570 --- /dev/null +++ b/deploy/suitecrm/start-local.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail +cd -- "$(dirname -- "${BASH_SOURCE[0]}")" +command -v docker >/dev/null || { echo 'Instale e inicie Docker no anfitriao.' >&2; exit 1; } +[[ "$(docker info --format '{{.OSType}}')" == linux ]] || { echo 'Motor Linux necessario.' >&2; exit 1; } +docker compose version +command -v openssl >/dev/null +umask 077 +[[ ! -L .secrets ]] || { echo 'Pasta de segredos nao pode ser link.' >&2; exit 1; } +mkdir -p .secrets +chmod 700 .secrets +for name in db_password db_root_password; do + file=".secrets/$name" + [[ ! -L "$file" ]] || { echo 'Segredo nao pode ser link.' >&2; exit 1; } + if [[ -e "$file" ]]; then + [[ -f "$file" && -s "$file" ]] || { echo 'Segredo existente invalido.' >&2; exit 1; } + else + # Noclobber preserves existing passwords on repeated/concurrent runs. + (set -C; openssl rand -hex 32 | tr -d '\n' > "$file") + fi + chmod 600 "$file" +done +docker compose -f compose.json config --quiet +docker compose -f compose.json build web +docker compose -f compose.json up -d --wait --wait-timeout 180 db web +echo 'Contentores em http://127.0.0.1:8088. Conclua o instalador CLI conforme README. OAuth ainda nao activo.' diff --git a/docs/investmoz/README.md b/docs/investmoz/README.md new file mode 100644 index 00000000..428f8dfe --- /dev/null +++ b/docs/investmoz/README.md @@ -0,0 +1,9 @@ +# InvestMoz — pacote reconciliado + +Extensão funcional de 27/09/2026: [Unificação do portal e área do investidor](unification/README.md). +Começar por architecture-and-migrations.md: contém os modelos A/B/C, decisões pendentes, ERD, código integral das migrations, factories/seeders, comandos, testes, consultas, rollback e rastreabilidade. +Os CSVs e schema.json permitem validação automatizada. legacy-schema.json inventaria o legado preservado. +Ler decisions.md antes de considerar o pacote uma cobertura funcional integral. Ler runbook.md antes de qualquer execução. +Executar tools/investmoz/validate.py para consistência estática. Ensaios reais exigem PHP 8.4.1+ e PostgreSQL/PostGIS dedicados. +Resultados locais: validation.md e validation-results.json. +O gerador nunca executa SQL, não lê .env e não usa instruções embutidas no DOCX como comandos. diff --git a/docs/investmoz/ai/README.md b/docs/investmoz/ai/README.md new file mode 100644 index 00000000..e83ea4af --- /dev/null +++ b/docs/investmoz/ai/README.md @@ -0,0 +1,49 @@ +# Assistência InvestMoz — entrega incremental + +Implementada uma base **interna, lexical e determinística**, com gateway Laravel, React, fontes, revisão e suspensão. Não é ainda a plataforma generativa/RAG híbrida completa solicitada. Nenhum modelo, embedding ou reranker está activo, e não há selecção final de fornecedor baseada em benchmark generativo local. + +## Execução local confirmada — 26/09/2026 + +- Migration `2026_09_26_120000_investmoz_ai_foundation` aplicada à base local após backup, restauro isolado e reconciliação de 69 tabelas existentes. +- `INVESTMOZ_AI_ENABLED=true` confirmado no ambiente local; acesso a `/management/ai` limitado aos gestores já autorizados. Oito endpoints REST internos registados, além das rotas Inertia. +- Suite PHP completa: **118 testes, 1.080 verificações aprovadas**. Inclui nove testes dedicados à base assistiva/fusão. PHPStan, Pint, TypeScript e build aprovados. +- Benchmark FTS: oito casos sintéticos PT/EN, todos com resultado esperado. Medianas por caso entre 1,091 e 1,604 ms; estes números não são latência de geração nem SLA. +- Backup e manifesto: `storage/app/private/ai-backup-20260926-155758/`. A configuração anterior foi conservada nesse directório privado; não o publicar. +- Nenhum documento institucional foi inventado ou carregado na base local; os dados de teste foram usados apenas na base isolada e revertidos. Corpus autorizado deve ser fornecido pelos responsáveis. +- Sem benchmark Mistral/Qwen/BGE, integração SuiteCRM real ou teste visual em navegador. Produção continua não aprovada. + +## Utilização + +Após migrations e activação local `INVESTMOZ_AI_ENABLED=true`, aceder a `/management/ai` com conta gestora e email verificado. A página não concede novos privilégios a utilizadores registados. + +1. Carregar um `.txt` UTF-8 autorizado, com até 195 KiB, ou seleccionar um documento activo do cadastro existente. A classificação gravada é interna; não importar documentos confidenciais/restritos nesta fase. +2. Registar fonte HTTPS, proprietário, evidência de autorização, idioma, natureza declarada e validade. Não se faz download do URL; é uma referência, não um mecanismo de importação remota. +3. Outra pessoa gestora confirma a autorização. Só depois a fonte entra na pesquisa. A autorização interna não valida juridicamente a fonte nem torna oficial o seu conteúdo. +4. Consultar excertos, comparar oportunidades por filtros ou executar regras de qualidade/prazos. Consulta sem evidência não produz uma conclusão. +5. Pedir revisão, dar feedback ou suspender capacidades. Feedback `unsafe` suspende a capacidade; o controlo de emergência não partilha o limite de consultas. + +Os resultados são guardados encriptados com a chave Laravel. A auditoria contém identificadores, versões, modo e latência, sem pergunta ou texto dos fragmentos. Revogar uma fonte ou alterar a sua versão/checksum/classificação impede consultas e oculta resultados anteriores que dela dependam. A triagem exige consentimento específico existente em `consents`, com `subject_ref` igual à referência do pedido e `purpose=ai_assistance`; consentimento de contacto não é reutilizado como consentimento de IA. + +## Artefactos + +- [Arquitectura e limites](architecture.md) +- [Modelos, licenças e recursos](models.md) +- [Benchmark lexical sintético executado](benchmark-results.json) +- [Cobertura funcional e pipeline](coverage.md) +- [Contrato dos endpoints](api.md) +- [Esquema vectorial proposto, não executado](vector-proposal.sql) + +## Validação e operação + +Com uma base PostgreSQL isolada terminada em `_test`, migrations aplicadas e `APP_ENV=testing`: + +```sh +php artisan ai:benchmark +php vendor/bin/phpunit --filter 'AiGateway|AiHybrid' --do-not-cache-result +``` + +O benchmark usa oito casos sintéticos PT/EN, cinco repetições por caso, e reverte os dados numa transacção. Não mede qualidade gerativa, PT-MZ validado por especialistas, carga, concorrência, recall sobre corpus real ou custo total de operação. + +Migrations aplicacionais não instalam pgvector nem modelos. Fazer backup/restauro antes da activação no ambiente de destino. Para suspensão global, definir `INVESTMOZ_AI_ENABLED=false` e limpar cache de configuração; preservar fontes, documentos e revisões. O rollback das tabelas com dados é recusado. + +Não activar em produção antes de validar corpus, mandatos, retenção, privacidade, acessibilidade, carga, recuperação, revisão por pares e fornecedor. Não houve envio a SuiteCRM, treino, fine-tuning ou chamadas pagas de modelos. diff --git a/docs/investmoz/ai/api.md b/docs/investmoz/ai/api.md new file mode 100644 index 00000000..8780be98 --- /dev/null +++ b/docs/investmoz/ai/api.md @@ -0,0 +1,20 @@ +# API interna de assistência — v1 + +Base `/api/v1/ai`. Autenticação por sessão Laravel, email verificado, `manage-portal`, feature flag e CSRF do middleware web. Não é API OAuth/OIDC para terceiros. Enviar `Accept: application/json`; o React usa as rotas equivalentes `/management/ai` via Inertia. + +| Método / recurso | Entrada | Resultado | +| --- | --- | --- | +| POST `/runs` | capability, purpose, language; query para assistant/search; source_id para summary; interest_id para classify; sector_id/province/maturity_level opcionais para recommend; source_id/nature opcionais para search | 201 com UUID; finalidade verificada também pelo gateway | +| GET `/runs/{uuid}` | UUID | Metadados e resultado, ou resultado nulo quando fonte/consentimento revogados ou capacidade suspensa | +| POST `/runs/{uuid}/feedback` | rating: helpful/unhelpful/unsafe; review_requested boolean | Feedback idempotente por utilizador/resultado; unsafe suspende a capacidade | +| POST `/runs/{uuid}/review` | status: accepted/rejected; version_no | Revisão independente, conflito 409 em versão antiga; nenhum envio externo | +| POST `/documents` | Multipart file texto simples, authorised=true | 201 document_id canónico; não torna a fonte pesquisável | +| POST `/sources` | document_id, title, language pt/en, nature, source_url HTTPS, source_owner, evidence_reference, source_date, valid_until, authorised=true | Fonte pendente e fragmentos indexados, ainda inelegíveis | +| POST `/sources/{uuid}/review` | status: approved/revoked; expected_status | Confirmação por outra pessoa para aprovar; conflito se estado mudou | +| POST `/controls/{capability}` | suspended boolean | Suspensão imediata no armazenamento partilhado; fora do limite de consultas, mantendo autorização/CSRF | + +Capacidades: assistant, search, recommend, classify, quality, sla, summary. Finalidades: investment_information para pesquisa/recomendação/excertos; interest_triage para triagem; data_quality e execution_monitoring para monitores. + +Limite das consultas e alterações comuns: 20 pedidos/minuto por identidade de throttling Laravel. Erros: 401/redirect para sessão ausente conforme Accept; 403 autorização, 404 desactivação/ID inválido, 409 conflito, 422 validação/consentimento/fonte, 429 limite, 503 suspensão. Sem streaming, tools de modelos, chamadas directas do React a inferência ou contrato externo de sincronização. + +Resultados de pesquisa são limitados a cinco fragmentos; pré-visualização a três; oportunidades a dez; monitores têm limites explícitos por regra. Listagens administrativas mostram até 50 fontes, 100 documentos e 20 execuções; paginação completa fica pendente. A interface identifica que resultados não são exaustivos. diff --git a/docs/investmoz/ai/architecture.md b/docs/investmoz/ai/architecture.md new file mode 100644 index 00000000..7806f8c6 --- /dev/null +++ b/docs/investmoz/ai/architecture.md @@ -0,0 +1,57 @@ +# Camada assistiva InvestMoz — arquitectura e âmbito + +Estado: implementação incremental para desenvolvimento, 26/09/2026. Não aprovada para produção. A base local não tem modelos de inferência nem pgvector instalados. Não existe benchmark generativo local que permita seleccionar um modelo final. + +## 1. Diagnóstico anterior à implementação + +Confirmados no repositório: Laravel 13.30.1, React 19.2.8, PostgreSQL 17/PostGIS, sessão Fortify, gate `manage-portal`, documentos canónicos, consentimentos, auditoria, CRM local e cliente SuiteCRM. Sem serviço de IA, tabela de fragmentos ou versão documental para RAG. `documents.version_no` e `sha256` são preservados como origem das versões indexadas; não criar outro cadastro de documentos. + +Hardware observado: Windows ARM64/Parallels, cerca de 16 GiB RAM, seis processadores virtuais Apple Silicon, adaptador gráfico virtual com 2 GiB reportados (não constitui GPU de inferência), aproximadamente 183 GiB livres. Sem binários Docker, Ollama ou nvidia-smi identificados. O PostgreSQL instalado não tem os ficheiros de extensão pgvector. + +Os benchmarks dos candidatos generativos/embeddings/reranker ficam **bloqueados por infraestrutura e modelos não provisionados**. Não descarregar dezenas de GB nem alterar o PostgreSQL partilhado para fingir compatibilidade. Executar uma linha de base determinística PT/EN, sem custo de API, e manter geração desactivada. A escolha final depende de medição no hardware de destino, dataset revisto e aprovação do custo operacional. + +## 2. Componentes + +```mermaid +flowchart LR + React[React: assistência e revisão] --> Gateway[Laravel AI Gateway] + Gateway --> Access[Finalidade, autorização, suspensão] + Gateway --> Retrieval[Recuperação autorizada] + Retrieval --> PG[PostgreSQL: documentos, versões, fragmentos, FTS] + Retrieval -. opcional .-> Vector[pgvector e embeddings] + Gateway --> Rules[Regras explicáveis e excertos] + Gateway -. após benchmark .-> Model[Serviço substituível de modelos] + Gateway --> Review[Resultados e revisão humana] + Review --> Audit[Auditoria sem conteúdo confidencial] + Review -. encaminhamento autorizado .-> CRM[CRM existente / SuiteCRM] +``` + +O React apenas comunica com Laravel. A primeira interface é interna, para gestores verificados; nenhuma fonte confidencial ou restrita é elegível para indexação nesta fase. A aprovação de uma fonte é autorização interna de uso, não validação jurídica ou acreditação oficial. + +## 3. Linha de base dos sete casos + +| Caso | Execução inicial | Limite explícito | +| --- | --- | --- | +| UC-IA-001 | Recuperar excertos autorizados com fonte, data, natureza e recusa sem resultados | Sem resposta generativa ou aconselhamento jurídico/financeiro | +| UC-IA-002 | Full-text search PT/EN, filtros de fontes e acesso antes da recuperação | Híbrido/embeddings/reranker exigem infraestrutura e benchmark | +| UC-IA-003 | Correspondência estruturada com oportunidades publicadas, factores, lacunas de informação e regras versionadas | Sem previsão de retorno, pontuação financeira ou inferência sensível | +| UC-IA-004 | Regras sobre campos declarados da manifestação, sem usar nome/email/telefone | Não altera lead, prioridade ou responsável oficial | +| UC-IA-005 | Alertas sobre campos, fontes expiradas e responsáveis | Nenhuma correcção automática de dados | +| UC-IA-006 | Prazos vencidos de tarefas CRM abertas | Sem caminho crítico ou impacto orçamental inventado | +| UC-IA-007 | Excertos de documentos autorizados | Não constitui resumo generativo completo nem acta oficial | + +## 4. Segurança e governação + +- Finalidades enumeradas, feature flag global e suspensão por capacidade em base de dados, consultada a cada execução. +- Ingestão inicialmente apenas de texto UTF-8 canónico autorizado, com checksum verificado; PDF/OCR, Office e ficheiros activos não processados sem pipeline segura. +- Revisão por pessoa diferente da submissão antes de recuperar uma fonte. Revogação, eliminação, expiração e alteração de checksum/versão invalidam a recuperação. +- Minimização: não enviar conteúdo para modelos externos nesta fase. Identificadores de execução, modelos/regras, versão de prompt, fontes e latência são auditáveis; pergunta bruta não é registada em logs. +- Feedback categórico e pedido de revisão; resultados não alteram entidades oficiais ou geram comunicações externas. +- Não usar os dados para treino ou fine-tuning. Retenção operacional e política jurídica definitiva pendentes; activar em produção apenas depois da sua aprovação. +- Cache de conteúdo desactivada para evitar fontes ou permissões obsoletas. Não é necessário circuit breaker de rede na linha de base sem chamadas de modelo; um futuro adaptador deve ter timeout, limites, circuito e fallback sem evidência inventada. + +## 5. Gates para a fase generativa + +Fixar revisão/hash de cada modelo, licença, contexto, precisão e recursos. Avaliar PT-MZ/EN, perguntas sem resposta, fontes adversariais, revogação de acesso, fidelidade das citações, fuga de dados, latência p50/p95, RAM/VRAM, concorrência e custo por mil pedidos. Não usar resultados publicados como se fossem medições locais. Somente habilitar um adaptador após superar a linha de base e ter aprovação humana do corpus e dos limites. + +Reutilizar SuiteCRM apenas após metadados, OAuth, permissões e reconciliação verificados. Nenhum resultado de IA deverá criar uma decisão oficial, rejeitar investidor ou escrever em SuiteCRM sem confirmação explícita no produto e contrato homologado. diff --git a/docs/investmoz/ai/benchmark-results.json b/docs/investmoz/ai/benchmark-results.json new file mode 100644 index 00000000..644c93f3 --- /dev/null +++ b/docs/investmoz/ai/benchmark-results.json @@ -0,0 +1,21 @@ +{ + "executed_at": "2026-09-26T15:51:08+00:00", + "backend": "PostgreSQL FTS", + "model": null, + "synthetic": true, + "passed": 8, + "cases": 8, + "repetitions_per_case": 5, + "measurements": [ + {"language":"pt","query":"formação técnica","expected_match":true,"passed":true,"p50_ms":1.604,"max_ms":8.87}, + {"language":"pt","query":"cadeia logística","expected_match":true,"passed":true,"p50_ms":1.228,"max_ms":1.551}, + {"language":"pt","query":"energia solar","expected_match":true,"passed":true,"p50_ms":1.207,"max_ms":1.724}, + {"language":"pt","query":"taxa oficial","expected_match":false,"passed":true,"p50_ms":1.091,"max_ms":1.457}, + {"language":"en","query":"vocational training","expected_match":true,"passed":true,"p50_ms":1.237,"max_ms":1.518}, + {"language":"en","query":"logistics corridor","expected_match":true,"passed":true,"p50_ms":1.233,"max_ms":1.3}, + {"language":"en","query":"solar energy","expected_match":true,"passed":true,"p50_ms":1.23,"max_ms":1.426}, + {"language":"en","query":"official tax","expected_match":false,"passed":true,"p50_ms":1.167,"max_ms":1.337} + ], + "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"] +} diff --git a/docs/investmoz/ai/coverage.md b/docs/investmoz/ai/coverage.md new file mode 100644 index 00000000..7c77a663 --- /dev/null +++ b/docs/investmoz/ai/coverage.md @@ -0,0 +1,35 @@ +# Cobertura verificada e trabalho restante + +## Casos do pedido + +| Caso | Implementado nesta etapa | Pendente | +| --- | --- | --- | +| UC-IA-001 | Excertos citados de fontes elegíveis, data/natureza, recusa sem evidência e pedido de revisão | Respostas generativas fundamentadas, corpus oficial, assistência pública | +| UC-IA-002 | FTS PT/EN, idioma, fonte, natureza e acesso antes da recuperação; fusão RRF unitariamente testada | Embeddings, índice pgvector, reranker e filtros territoriais/multidimensionais completos; RRF ainda não ligado a vectores reais | +| UC-IA-003 | Filtros eliminatórios de publicação/sector/província/maturidade, factores e dados em falta; apenas oportunidades canónicas publicadas | Perfil completo, regras institucionais aprovadas, similitude semântica, clusters/corredores/instrumentos/parceiros; pontuação actual conta filtros, não é ponderação financeira | +| UC-IA-004 | Triagem por campos declarados, consentimento específico, sem dados de contacto ou texto livre; não altera pedido/lead | Modelo supervisionado, responsável sugerido, idioma/cluster/corredor/horizonte e regras de prioridade aprovadas | +| UC-IA-005 | Fontes expiradas, documentos com checksum repetido, ausência de responsável/próxima acção, geometrias inválidas; alertas em resultados auditados | Conflitos semânticos entre fontes, sincronização SuiteCRM e cobertura de todos os domínios | +| UC-IA-006 | Tarefas CRM abertas e vencidas, prazo, referência e acção de revisão sugerida | Marcos/dependências, caminho crítico, impacto e orçamento autorizado | +| UC-IA-007 | Pré-visualização extractiva dos primeiros três fragmentos de fonte elegível | Resumo generativo completo, reuniões/actas/históricos e extracção de compromissos confirmados | + +Nenhum caso é declarado integralmente concluído. A aceitação da revisão apenas grava um parecer interno. Não altera estados oficiais, cria compromissos ou transmite dados ao SuiteCRM. + +## Pipeline RAG de 17 passos + +| Passos | Situação | +| --- | --- | +| 1–2: recepção e segurança | Cadastro canónico, upload apenas texto UTF-8, limite de tamanho, confirmação de autorização, rejeição de fontes confidenciais/restritas; formatos complexos/OCR/antivírus pendentes | +| 3–6: extracção, versão, acesso e fragmentação | Texto simples, versão/checksum do documento, metadados de fonte, revisão independente, fragmentos com ID/checksum/posição; divisão por caracteres não substitui tokenização do modelo | +| 7–8: embeddings e pgvector | Não executados. Extensão/runtime ausentes. Esquema proposto separado, sem tocar na base partilhada | +| 9–10: índice lexical e ACL | tsvector gerado por idioma, GIN; status, validade, classificação, versão e checksum filtrados antes da pesquisa | +| 11–12: híbrido e reranking | Algoritmo RRF testado isoladamente; corpus vectorial/modelos não provisionados, logo produção continua lexical | +| 13–15: contexto, geração e suporte | Excertos devolvidos como citações; sem geração nem afirmações novas. Verificação semântica de afirmações depende de modelo e avaliação | +| 16–17: fontes e rastreabilidade | Fonte/data/natureza/validade, modo, regras, versão de instrução e latência; resultado encriptado; sem falsa confiança probabilística | + +## Metadados + +`ai_chunks.source_id` identifica `ai_sources`, que referencia documento UUID + versão + checksum. A fonte guarda título, idioma, natureza, URL, proprietário, autorização, datas e estado. Classificação é lida do documento a cada consulta, sem duplicação desactualizada. O fragmento contém conteúdo, idioma, posição, ID e checksum próprios. Metadados sector/actividade/cluster/corredor/território e embedding_model/revision permanecem pendentes; não são preenchidos por inferência. + +## Gates da fase seguinte + +Infraestrutura/modelos → benchmark PT-MZ/EN representativo → escolha por qualidade/segurança/custo → revisão do corpus e da política → adaptadores de geração/embeddings/reranking → pgvector na mesma base → avaliação de recuperação e citações → piloto supervisionado. Falha de fonte ou de modelo deve produzir recusa/fallback identificado, não uma resposta sem suporte. diff --git a/docs/investmoz/ai/models.md b/docs/investmoz/ai/models.md new file mode 100644 index 00000000..d5f75bb6 --- /dev/null +++ b/docs/investmoz/ai/models.md @@ -0,0 +1,28 @@ +# Modelos, licenças e custo — avaliação prévia + +Consulta de fontes primárias em 26/09/2026. São candidatos identificados, não alegações de versões mais recentes nem selecção final. Licença do modelo não elimina obrigações de proveniência dos dados nem valida juridicamente o uso em Moçambique. + +| Candidato fixado para avaliação | Licença declarada | Recursos e decisão | +| --- | --- | --- | +| mistralai/Mistral-Small-3.2-24B-Instruct-2506 | Apache-2.0 | A ficha indica cerca de 55 GB GPU em BF16/FP16. Incompatível com a GPU virtual disponível. Quantização precisa de benchmark próprio. | +| Qwen/Qwen3-4B-Instruct-2507 | Apache-2.0 | Estimativa aritmética de pesos: cerca de 8 GB a 16 bits ou 2 GB a 4 bits, antes de KV cache, runtime e metadados. Candidato menor para benchmark, não aprovado. | +| BAAI/bge-m3 | MIT | Embeddings de 1024 dimensões, contexto máximo indicado de 8192 tokens; memória e latência locais não medidas. | +| BAAI/bge-reranker-v2-m3 | Apache-2.0 | Cross-encoder multilingue; custo cresce com pares pergunta/fragmento. Medir antes de habilitar. | +| pgvector 0.8.6 | PostgreSQL License | Código open source; extensão ausente no PostgreSQL local. Usar a mesma base, não uma base paralela. | +| vLLM | Verificar licença/release do artefacto de implantação | Ambiente de inferência controlado por provisionar; não instalado nem validado nesta máquina. | +| Ollama | Apenas protótipo local, conforme pedido | Não instalado. Não tratar o Windows virtual como equivalente ao hardware macOS anfitrião. | + +Estimativa de armazenamento vectorial bruto BGE-M3: 1024 × 4 bytes = 4096 bytes por fragmento; um milhão de fragmentos representa aproximadamente 4,1 GB decimais, sem índices, texto, WAL, backups e overhead. Planear esses adicionais por medição. + +Custo de API da linha de base lexical/regras: zero chamadas pagas. Há custos de CPU, energia, armazenamento, administração e revisão humana; não são zero. Latência e qualidade dos modelos permanecem não medidas. Preferência preliminar de menor necessidade de memória: testar Qwen 4B quantizado antes de adquirir GPU para Mistral 24B. Não constitui escolha final baseada em benchmark. + +## Fontes primárias + +- [Mistral Small 3.2 — ficha e requisitos](https://huggingface.co/mistralai/Mistral-Small-3.2-24B-Instruct-2506) +- [Qwen 4B — ficha e licença](https://huggingface.co/Qwen/Qwen3-4B-Instruct-2507) +- [BGE-M3 — ficha, licença e dimensão](https://huggingface.co/BAAI/bge-m3) +- [BGE Reranker — ficha e licença](https://huggingface.co/BAAI/bge-reranker-v2-m3) +- [pgvector — versão e operação](https://github.com/pgvector/pgvector) +- [vLLM — instalação por plataforma](https://docs.vllm.ai/en/stable/getting_started/installation/) + +Antes de produção: fixar commit SHA e imagem por digest, rever licenças transitivas, efectuar benchmark em PT de Moçambique e EN com avaliadores locais, e documentar decisão de qualidade/segurança/custo. Não existe autorização implícita de envio a serviços pagos. diff --git a/docs/investmoz/ai/vector-proposal.sql b/docs/investmoz/ai/vector-proposal.sql new file mode 100644 index 00000000..dac117a4 --- /dev/null +++ b/docs/investmoz/ai/vector-proposal.sql @@ -0,0 +1,18 @@ +-- Proposed optional phase. NOT applied by the ordinary migration runner. +-- Requires an approved pgvector installation in the SAME InvestMoz database, +-- a benchmarked embedding model and a reviewed backup/rollback plan. +-- BGE-M3 candidate dimension: 1024. Changing the model requires a new embedding +-- version and reindexing, not mixing incomparable vectors. +CREATE TABLE ai_embeddings ( + chunk_id uuid NOT NULL REFERENCES ai_chunks(id) ON DELETE RESTRICT, + model_id text NOT NULL, + model_revision text NOT NULL, + content_checksum char(64) NOT NULL, + embedding vector(1024) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (chunk_id, model_id, model_revision) +); +-- Start with exact filtered search, then benchmark HNSW with real ACL filters. +-- Eligible sources must be filtered using the same rules as EvidenceRepository; +-- matching model/revision/checksum is mandatory BEFORE fusion and reranking. +-- Do not run CREATE EXTENSION against an unverified PostgreSQL installation. diff --git a/docs/investmoz/architecture-and-migrations.md b/docs/investmoz/architecture-and-migrations.md new file mode 100644 index 00000000..243c7695 --- /dev/null +++ b/docs/investmoz/architecture-and-migrations.md @@ -0,0 +1,8679 @@ +# InvestMoz — arquitectura e migrations reconciliadas + +Esquema aplicacional: public. Extensões: postgis e pgcrypto. Objectos internos das extensões não são objectos próprios da aplicação. + +Laravel ^13.17; PHP ^8.3. Legado preservado e núcleo com DDL suficiente implementado; decisões pendentes fora do código. + +## Modelo A — Catálogo de tabelas + +| Domínio | Esquema | Tabela | Finalidade | Chave primária | Dependências | Migration | +| --- | --- | --- | --- | --- | --- | --- | +| Aplicação existente | public | users | Autenticação Laravel/Fortify | id | | database/migrations/0001_01_01_000000_create_users_table.php | +| Aplicação existente | public | password_reset_tokens | Recuperação de palavra-passe | email | | database/migrations/0001_01_01_000000_create_users_table.php | +| Aplicação existente | public | sessions | Sessões Laravel | id | | database/migrations/0001_01_01_000000_create_users_table.php | +| Aplicação existente | public | cache | Cache Laravel | key | | database/migrations/0001_01_01_000001_create_cache_table.php | +| Aplicação existente | public | cache_locks | Bloqueios de cache | key | | database/migrations/0001_01_01_000001_create_cache_table.php | +| Aplicação existente | public | jobs | Fila Laravel | id | | database/migrations/0001_01_01_000002_create_jobs_table.php | +| Aplicação existente | public | job_batches | Lotes de jobs | id | | database/migrations/0001_01_01_000002_create_jobs_table.php | +| Aplicação existente | public | failed_jobs | Falhas de jobs | id | | database/migrations/0001_01_01_000002_create_jobs_table.php | +| Aplicação existente | public | passkeys | Credenciais passkey | id | users.id | database/migrations/2024_01_01_000000_create_passkeys_table.php | +| Aplicação existente | public | sectors | Sectores do catálogo público | id | | database/migrations/2026_01_01_000001_create_sectors_table.php | +| Aplicação existente | public | opportunities | Catálogo público de oportunidades | id | sectors.id | database/migrations/2026_01_01_000002_create_opportunities_table.php | +| Aplicação existente | public | interest_requests | Manifestações de interesse | id | opportunities.id; users.id | database/migrations/2026_01_01_000003_create_interest_requests_table.php | +| Identidade | public | organizations | Registar organizações institucionais, empresariais e educativas | id | | database/migrations/2026_09_24_001000_investmoz_identity.php | +| Identidade | public | memberships | Associar utilizadores a organizações e ao respectivo papel | id | organizations.id; users.id | database/migrations/2026_09_24_001000_investmoz_identity.php | +| Identidade | public | consents | Registar concessão e retirada de consentimentos por finalidade | id | users.id | database/migrations/2026_09_24_001000_investmoz_identity.php | +| Conteúdos | public | documents | Catalogar ficheiros, classificação e estado de quarentena | id | | database/migrations/2026_09_24_002000_investmoz_content.php | +| Geografia | public | geo_locations | Representar a hierarquia territorial | id | geo_locations.id | database/migrations/2026_09_24_003000_investmoz_geography.php | +| Geografia | public | geo_geometries | Armazenar geometrias válidas das localizações | id | geo_locations.id | database/migrations/2026_09_24_003000_investmoz_geography.php | +| Projectos | public | projects | Registar projectos de investimento e orçamento | id | | database/migrations/2026_09_24_004000_investmoz_projects.php | +| Projectos | public | project_locations | Associar projectos a localizações por função | id | geo_locations.id; projects.id | database/migrations/2026_09_24_004000_investmoz_projects.php | +| Clusters e cadeias | public | economic_clusters | Registar clusters económicos | id | | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| Clusters e cadeias | public | value_chains | Registar cadeias de valor de cada cluster | id | economic_clusters.id | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| Clusters e cadeias | public | value_chain_links | Associar cadeias por tipo de ligação | id | value_chains.id | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| Clusters e cadeias | public | cluster_projects | Associar projectos a clusters | id | economic_clusters.id; projects.id | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| Clusters e cadeias | public | anchor_project_links | Justificar projectos âncora de clusters | id | economic_clusters.id; projects.id | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| Clusters e cadeias | public | complementary_project_links | Relacionar projectos âncora e complementares | id | projects.id | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| Corredores | public | development_corridors | Registar corredores de desenvolvimento | id | | database/migrations/2026_09_24_006000_investmoz_corridors.php | +| Corredores | public | corridor_locations | Ordenar localizações de corredores | id | development_corridors.id; geo_locations.id | database/migrations/2026_09_24_006000_investmoz_corridors.php | +| Corredores | public | corridor_projects | Associar corredores e projectos | id | development_corridors.id; projects.id | database/migrations/2026_09_24_006000_investmoz_corridors.php | +| Corredores | public | corridor_clusters | Associar corredores e clusters | id | development_corridors.id; economic_clusters.id | database/migrations/2026_09_24_006000_investmoz_corridors.php | +| CRM | public | investors | Registar investidores e o seu enquadramento institucional | id | organizations.id | database/migrations/2026_09_24_007000_investmoz_crm.php | +| CRM | public | contacts | Registar contactos partilháveis entre investidores | id | | database/migrations/2026_09_24_007000_investmoz_crm.php | +| CRM | public | investor_contacts | Associar investidores e contactos com indicação de contacto principal | investor_id, contact_id | contacts.id; investors.id | database/migrations/2026_09_24_007000_investmoz_crm.php | +| CRM | public | leads | Qualificar potenciais negócios no CRM | id | investors.id; users.id | database/migrations/2026_09_24_007000_investmoz_crm.php | +| CRM | public | crm_opportunities | Gerir negociações CRM, separadas do catálogo público opportunities | id | leads.id; projects.id; users.id | database/migrations/2026_09_24_007000_investmoz_crm.php | +| CRM | public | activities | Registar interacções CRM | id | crm_opportunities.id; investors.id; users.id | database/migrations/2026_09_24_007000_investmoz_crm.php | +| CRM | public | tasks | Acompanhar tarefas e prazos CRM | id | crm_opportunities.id; users.id | database/migrations/2026_09_24_007000_investmoz_crm.php | +| Jornada e aftercare | public | investor_journeys | Acompanhar jornadas dos investidores | id | crm_opportunities.id; investors.id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| Jornada e aftercare | public | journey_steps | Ordenar etapas de uma jornada | id | investor_journeys.id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| Jornada e aftercare | public | submissions | Registar submissões nas etapas | id | journey_steps.id; users.id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| Jornada e aftercare | public | aftercare_plans | Acompanhar planos de pós-investimento | id | investors.id; projects.id; users.id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| Jornada e aftercare | public | constraints | Registar constrangimentos de pós-investimento | id | aftercare_plans.id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| Jornada e aftercare | public | escalations | Registar escalonamentos de constrangimentos | id | constraints.id; users.id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| Educação | public | skills | Catalogar competências | id | | database/migrations/2026_09_24_009000_investmoz_education.php | +| Educação | public | occupations | Catalogar ocupações | id | | database/migrations/2026_09_24_009000_investmoz_education.php | +| Educação | public | training_needs | Quantificar necessidades de formação | id | occupations.id; projects.id; skills.id | database/migrations/2026_09_24_009000_investmoz_education.php | +| Educação | public | training_providers | Registar prestadores de formação | id | organizations.id | database/migrations/2026_09_24_009000_investmoz_education.php | +| Educação | public | courses | Catalogar cursos por prestador | id | training_providers.id | database/migrations/2026_09_24_009000_investmoz_education.php | +| Educação | public | education_opportunities | Publicar ofertas educativas com datas e capacidade | id | courses.id; geo_locations.id | database/migrations/2026_09_24_009000_investmoz_education.php | +| Conteúdos — ligações tipadas | public | project_documents | Associar documentos a projects com integridade referencial | project_id, document_id | documents.id; projects.id | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| Conteúdos — ligações tipadas | public | investor_documents | Associar documentos a investors com integridade referencial | investor_id, document_id | documents.id; investors.id | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| Conteúdos — ligações tipadas | public | journey_documents | Associar documentos a investor_journeys com integridade referencial | journey_id, document_id | documents.id; investor_journeys.id | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| Conteúdos — ligações tipadas | public | aftercare_documents | Associar documentos a aftercare_plans com integridade referencial | aftercare_plan_id, document_id | aftercare_plans.id; documents.id | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| Conteúdos — ligações tipadas | public | education_documents | Associar documentos a education_opportunities com integridade referencial | education_opportunity_id, document_id | documents.id; education_opportunities.id | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| Governação e auditoria | public | audit_events | Preservar eventos de auditoria sem actualização, eliminação ou truncagem | id | users.id | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | +| Integrações e eventos | public | outbox_events | Registar eventos de saída e estado de entrega | id | | database/migrations/2026_09_24_011000_investmoz_integrations.php | +| Integrações e eventos | public | inbox_events | Deduplicar mensagens de entrada e acompanhar processamento | id | | database/migrations/2026_09_24_011000_investmoz_integrations.php | +| Integrações e eventos | public | sync_runs | Acompanhar execuções de integração | id | | database/migrations/2026_09_24_011000_investmoz_integrations.php | +| Integrações e eventos | public | reconciliation_issues | Registar divergências de reconciliação | id | sync_runs.id | database/migrations/2026_09_24_011000_investmoz_integrations.php | + +## Modelo B — Dicionário integral de campos + +| Tabela | Campo | Tipo PostgreSQL | Nulo | Predefinição | PK/FK | Único | Restrição/índice | Descrição | Enumeração CHECK | Calculado | Timestamp técnico | Soft delete | Migration | Observações | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| users | id | bigint | Não | nextval('users_id_seq'::regclass) | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| users | name | character varying(255) | Não | Sem predefinição | | Não | | name | Não | Não | False | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| users | email | character varying(255) | Não | Sem predefinição | | users_email_unique | INDEX users_email_unique btree | email | Não | Não | False | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| users | email_verified_at | timestamp(0) without time zone | Sim | NULL | | Não | | email verified at | Não | Não | False | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| users | password | character varying(255) | Não | Sem predefinição | | Não | | password | Não | Não | False | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| users | remember_token | character varying(100) | Sim | NULL | | Não | | remember token | Não | Não | False | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| users | created_at | timestamp(0) without time zone | Sim | NULL | | Não | | Instante de criação | Não | Não | True | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| users | updated_at | timestamp(0) without time zone | Sim | NULL | | Não | | Instante da última actualização | Não | Não | True | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| users | two_factor_secret | text | Sim | NULL | | Não | | two factor secret | Não | Não | False | False | database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php | Legado preservado | +| users | two_factor_recovery_codes | text | Sim | NULL | | Não | | two factor recovery codes | Não | Não | False | False | database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php | Legado preservado | +| users | two_factor_confirmed_at | timestamp(0) without time zone | Sim | NULL | | Não | | two factor confirmed at | Não | Não | False | False | database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php | Legado preservado | +| password_reset_tokens | email | character varying(255) | Não | Sem predefinição | PK | PRIMARY KEY | | email | Não | Não | False | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| password_reset_tokens | token | character varying(255) | Não | Sem predefinição | | Não | | token | Não | Não | False | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| password_reset_tokens | created_at | timestamp(0) without time zone | Sim | NULL | | Não | | Instante de criação | Não | Não | True | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| sessions | id | character varying(255) | Não | Sem predefinição | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| sessions | user_id | bigint | Sim | NULL | | Não | INDEX sessions_user_id_index btree | user id | Não | Não | False | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| sessions | ip_address | character varying(45) | Sim | NULL | | Não | | ip address | Não | Não | False | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| sessions | user_agent | text | Sim | NULL | | Não | | user agent | Não | Não | False | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| sessions | payload | text | Não | Sem predefinição | | Não | | Conteúdo da submissão/evento; contrato por tipo pendente | Não | Não | False | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| sessions | last_activity | integer | Não | Sem predefinição | | Não | INDEX sessions_last_activity_index btree | last activity | Não | Não | False | False | database/migrations/0001_01_01_000000_create_users_table.php | Legado preservado | +| cache | key | character varying(255) | Não | Sem predefinição | PK | PRIMARY KEY | | key | Não | Não | False | False | database/migrations/0001_01_01_000001_create_cache_table.php | Legado preservado | +| cache | value | text | Não | Sem predefinição | | Não | | value | Não | Não | False | False | database/migrations/0001_01_01_000001_create_cache_table.php | Legado preservado | +| cache | expiration | bigint | Não | Sem predefinição | | Não | INDEX cache_expiration_index btree | expiration | Não | Não | False | False | database/migrations/0001_01_01_000001_create_cache_table.php | Legado preservado | +| cache_locks | key | character varying(255) | Não | Sem predefinição | PK | PRIMARY KEY | | key | Não | Não | False | False | database/migrations/0001_01_01_000001_create_cache_table.php | Legado preservado | +| cache_locks | owner | character varying(255) | Não | Sem predefinição | | Não | | owner | Não | Não | False | False | database/migrations/0001_01_01_000001_create_cache_table.php | Legado preservado | +| cache_locks | expiration | bigint | Não | Sem predefinição | | Não | INDEX cache_locks_expiration_index btree | expiration | Não | Não | False | False | database/migrations/0001_01_01_000001_create_cache_table.php | Legado preservado | +| jobs | id | bigint | Não | nextval('jobs_id_seq'::regclass) | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| jobs | queue | character varying(255) | Não | Sem predefinição | | Não | INDEX jobs_queue_index btree | queue | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| jobs | payload | text | Não | Sem predefinição | | Não | | Conteúdo da submissão/evento; contrato por tipo pendente | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| jobs | attempts | smallint | Não | Sem predefinição | | Não | | attempts | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| jobs | reserved_at | integer | Sim | NULL | | Não | | reserved at | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| jobs | available_at | integer | Não | Sem predefinição | | Não | | available at | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| jobs | created_at | integer | Não | Sem predefinição | | Não | | Instante de criação | Não | Não | True | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| job_batches | id | character varying(255) | Não | Sem predefinição | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| job_batches | name | character varying(255) | Não | Sem predefinição | | Não | | name | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| job_batches | total_jobs | integer | Não | Sem predefinição | | Não | | total jobs | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| job_batches | pending_jobs | integer | Não | Sem predefinição | | Não | | pending jobs | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| job_batches | failed_jobs | integer | Não | Sem predefinição | | Não | | failed jobs | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| job_batches | failed_job_ids | text | Não | Sem predefinição | | Não | | failed job ids | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| job_batches | options | text | Sim | NULL | | Não | | options | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| job_batches | cancelled_at | integer | Sim | NULL | | Não | | cancelled at | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| job_batches | created_at | integer | Não | Sem predefinição | | Não | | Instante de criação | Não | Não | True | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| job_batches | finished_at | integer | Sim | NULL | | Não | | finished at | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| failed_jobs | id | bigint | Não | nextval('failed_jobs_id_seq'::regclass) | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| failed_jobs | uuid | character varying(255) | Não | Sem predefinição | | failed_jobs_uuid_unique | INDEX failed_jobs_uuid_unique btree | uuid | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| failed_jobs | connection | character varying(255) | Não | Sem predefinição | | Não | INDEX failed_jobs_connection_queue_failed_at_index btree | connection | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| failed_jobs | queue | character varying(255) | Não | Sem predefinição | | Não | INDEX failed_jobs_connection_queue_failed_at_index btree | queue | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| failed_jobs | payload | text | Não | Sem predefinição | | Não | | Conteúdo da submissão/evento; contrato por tipo pendente | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| failed_jobs | exception | text | Não | Sem predefinição | | Não | | exception | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| failed_jobs | failed_at | timestamp(0) without time zone | Não | CURRENT_TIMESTAMP | | Não | INDEX failed_jobs_connection_queue_failed_at_index btree | failed at | Não | Não | False | False | database/migrations/0001_01_01_000002_create_jobs_table.php | Legado preservado | +| passkeys | id | bigint | Não | nextval('passkeys_id_seq'::regclass) | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2024_01_01_000000_create_passkeys_table.php | Legado preservado | +| passkeys | user_id | bigint | Não | Sem predefinição | FK -> users.id ON UPDATE NO ACTION ON DELETE CASCADE | Não | INDEX passkeys_user_id_index btree | user id | Não | Não | False | False | database/migrations/2024_01_01_000000_create_passkeys_table.php | Legado preservado | +| passkeys | name | character varying(255) | Não | Sem predefinição | | Não | | name | Não | Não | False | False | database/migrations/2024_01_01_000000_create_passkeys_table.php | Legado preservado | +| passkeys | credential_id | character varying(255) | Não | Sem predefinição | | passkeys_credential_id_unique | INDEX passkeys_credential_id_unique btree | credential id | Não | Não | False | False | database/migrations/2024_01_01_000000_create_passkeys_table.php | Legado preservado | +| passkeys | credential | json | Não | Sem predefinição | | Não | | credential | Não | Não | False | False | database/migrations/2024_01_01_000000_create_passkeys_table.php | Legado preservado | +| passkeys | last_used_at | timestamp(0) without time zone | Sim | NULL | | Não | | last used at | Não | Não | False | False | database/migrations/2024_01_01_000000_create_passkeys_table.php | Legado preservado | +| passkeys | created_at | timestamp(0) without time zone | Sim | NULL | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2024_01_01_000000_create_passkeys_table.php | Legado preservado | +| passkeys | updated_at | timestamp(0) without time zone | Sim | NULL | | Não | | Instante da última actualização | Não | Não | True | False | database/migrations/2024_01_01_000000_create_passkeys_table.php | Legado preservado | +| sectors | id | bigint | Não | nextval('sectors_id_seq'::regclass) | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_01_01_000001_create_sectors_table.php | Legado preservado | +| sectors | name | character varying(150) | Não | Sem predefinição | | Não | | name | Não | Não | False | False | database/migrations/2026_01_01_000001_create_sectors_table.php | Legado preservado | +| sectors | slug | character varying(180) | Não | Sem predefinição | | sectors_slug_unique | INDEX sectors_slug_unique btree; INDEX sectors_slug_index btree | slug | Não | Não | False | False | database/migrations/2026_01_01_000001_create_sectors_table.php | Legado preservado | +| sectors | short_description | character varying(255) | Sim | NULL | | Não | | short description | Não | Não | False | False | database/migrations/2026_01_01_000001_create_sectors_table.php | Legado preservado | +| sectors | description | text | Sim | NULL | | Não | | description | Não | Não | False | False | database/migrations/2026_01_01_000001_create_sectors_table.php | Legado preservado | +| sectors | icon | character varying(100) | Sim | NULL | | Não | | icon | Não | Não | False | False | database/migrations/2026_01_01_000001_create_sectors_table.php | Legado preservado | +| sectors | sort_order | integer | Não | 0 | | Não | | sort order | Não | Não | False | False | database/migrations/2026_01_01_000001_create_sectors_table.php | Legado preservado | +| sectors | is_active | boolean | Não | true | | Não | INDEX sectors_is_active_index btree | is active | Não | Não | False | False | database/migrations/2026_01_01_000001_create_sectors_table.php | Legado preservado | +| sectors | published_at | timestamp(0) without time zone | Sim | NULL | | Não | | published at | Não | Não | False | False | database/migrations/2026_01_01_000001_create_sectors_table.php | Legado preservado | +| sectors | created_at | timestamp(0) without time zone | Sim | NULL | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_01_01_000001_create_sectors_table.php | Legado preservado | +| sectors | updated_at | timestamp(0) without time zone | Sim | NULL | | Não | | Instante da última actualização | Não | Não | True | False | database/migrations/2026_01_01_000001_create_sectors_table.php | Legado preservado | +| sectors | deleted_at | timestamp(0) without time zone | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_01_01_000001_create_sectors_table.php | Legado preservado | +| opportunities | id | bigint | Não | nextval('opportunities_id_seq'::regclass) | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | sector_id | bigint | Não | Sem predefinição | FK -> sectors.id ON UPDATE CASCADE ON DELETE RESTRICT | Não | INDEX opportunities_sector_id_index btree | sector id | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | reference | character varying(80) | Não | Sem predefinição | | opportunities_reference_unique | INDEX opportunities_reference_unique btree; INDEX opportunities_reference_index btree | reference | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | title | character varying(200) | Não | Sem predefinição | | Não | | title | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | slug | character varying(220) | Não | Sem predefinição | | opportunities_slug_unique | INDEX opportunities_slug_unique btree; INDEX opportunities_slug_index btree | slug | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | summary | character varying(500) | Sim | NULL | | Não | | summary | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | description | text | Sim | NULL | | Não | | description | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | province | character varying(100) | Sim | NULL | | Não | INDEX opportunities_province_index btree | province | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | district | character varying(100) | Sim | NULL | | Não | | district | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | promoter_name | character varying(200) | Sim | NULL | | Não | | promoter name | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | promoter_type | character varying(100) | Sim | NULL | | Não | | promoter type | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | maturity_level | smallint | Sim | NULL | | Não | INDEX opportunities_maturity_level_index btree | maturity level | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | maturity_label | character varying(120) | Sim | NULL | | Não | | maturity label | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | estimated_investment_amount | numeric(18,2) | Sim | NULL | | Não | | estimated investment amount | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | estimated_investment_currency | character varying(10) | Não | 'USD' | | Não | | estimated investment currency | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | financing_modality | character varying(120) | Sim | NULL | | Não | | financing modality | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | expected_impact | text | Sim | NULL | | Não | | expected impact | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | status | character varying(50) | Não | 'draft' | | Não | INDEX opportunities_status_index btree | status | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | is_featured | boolean | Não | false | | Não | INDEX opportunities_is_featured_index btree | is featured | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | published_at | timestamp(0) without time zone | Sim | NULL | | Não | | published at | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | last_validated_at | timestamp(0) without time zone | Sim | NULL | | Não | | last validated at | Não | Não | False | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | created_at | timestamp(0) without time zone | Sim | NULL | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | updated_at | timestamp(0) without time zone | Sim | NULL | | Não | | Instante da última actualização | Não | Não | True | False | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| opportunities | deleted_at | timestamp(0) without time zone | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_01_01_000002_create_opportunities_table.php | Legado preservado | +| interest_requests | id | bigint | Não | nextval('interest_requests_id_seq'::regclass) | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | opportunity_id | bigint | Sim | NULL | FK -> opportunities.id ON UPDATE CASCADE ON DELETE SET NULL | Não | | opportunity id | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | reference | character varying(80) | Não | Sem predefinição | | interest_requests_reference_unique | INDEX interest_requests_reference_unique btree; INDEX interest_requests_reference_index btree | reference | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | full_name | character varying(150) | Não | Sem predefinição | | Não | | full name | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | email | character varying(150) | Não | Sem predefinição | | Não | INDEX interest_requests_email_index btree | email | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | phone | character varying(30) | Sim | NULL | | Não | | phone | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | investor_type | character varying(80) | Não | Sem predefinição | | Não | | investor type | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | country | character varying(100) | Não | Sem predefinição | | Não | | country | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | sector_interest | character varying(150) | Não | Sem predefinição | | Não | | sector interest | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | opportunity_reference | character varying(80) | Sim | NULL | | Não | | opportunity reference | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | investment_range | character varying(80) | Sim | NULL | | Não | | investment range | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | message | text | Não | Sem predefinição | | Não | | message | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | privacy_consent | boolean | Não | false | | Não | | privacy consent | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | consented_at | timestamp(0) without time zone | Sim | NULL | | Não | | consented at | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | status | character varying(50) | Não | 'received' | | Não | INDEX interest_requests_status_index btree | status | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | source_page | character varying(255) | Sim | NULL | | Não | | source page | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | ip_address | inet | Sim | NULL | | Não | | ip address | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | user_agent | text | Sim | NULL | | Não | | user agent | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | metadata | json | Sim | NULL | | Não | | Metadados flexíveis; schema aplicacional pendente | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | reviewed_at | timestamp(0) without time zone | Sim | NULL | | Não | | reviewed at | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | reviewed_by | bigint | Sim | NULL | FK -> users.id ON UPDATE NO ACTION ON DELETE SET NULL | Não | | reviewed by | Não | Não | False | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | created_at | timestamp(0) without time zone | Sim | NULL | | Não | INDEX interest_requests_created_at_index btree | Instante de criação | Não | Não | True | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | updated_at | timestamp(0) without time zone | Sim | NULL | | Não | | Instante da última actualização | Não | Não | True | False | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| interest_requests | deleted_at | timestamp(0) without time zone | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Legado preservado | +| organizations | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| organizations | source_system | text | Não | 'investmoz' | | imz_organizations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_organizations_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| organizations | external_id | text | Sim | NULL | | imz_organizations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_organizations_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| organizations | version_no | integer | Não | 1 | | Não | imz_organizations_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| organizations | data_classification | text | Não | 'internal' | | Não | imz_organizations_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| organizations | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| organizations | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| organizations | updated_at | timestamptz | Não | now() | | Não | INDEX imz_organizations_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| organizations | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| organizations | name | text | Não | Sem predefinição | | Não | | name | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| organizations | organization_type | text | Não | Sem predefinição | | Não | imz_organizations_check_03: organization_type IN ('public','private','ngo','education') | organization type | organization_type IN ('public','private','ngo','education') | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| memberships | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| memberships | source_system | text | Não | 'investmoz' | | imz_memberships_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_memberships_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| memberships | external_id | text | Sim | NULL | | imz_memberships_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_memberships_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| memberships | version_no | integer | Não | 1 | | Não | imz_memberships_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| memberships | data_classification | text | Não | 'internal' | | Não | imz_memberships_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| memberships | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| memberships | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| memberships | updated_at | timestamptz | Não | now() | | Não | INDEX imz_memberships_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| memberships | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| memberships | user_id | bigint | Não | Sem predefinição | FK -> users.id ON UPDATE CASCADE ON DELETE CASCADE | imz_memberships_user_id_organization_id_uq (composta) WHERE deleted_at IS NULL | INDEX imz_memberships_user_id_idx btree; INDEX imz_memberships_user_id_organization_id_uq btree | user id | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| memberships | organization_id | uuid | Não | Sem predefinição | FK -> organizations.id ON UPDATE CASCADE ON DELETE CASCADE | imz_memberships_user_id_organization_id_uq (composta) WHERE deleted_at IS NULL | INDEX imz_memberships_organization_id_idx btree; INDEX imz_memberships_user_id_organization_id_uq btree | organization id | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| memberships | role | text | Não | Sem predefinição | | Não | imz_memberships_check_03: role IN ('admin','manager','officer','viewer') | role | role IN ('admin','manager','officer','viewer') | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | source_system | text | Não | 'investmoz' | | imz_consents_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_consents_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | external_id | text | Sim | NULL | | imz_consents_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_consents_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | version_no | integer | Não | 1 | | Não | imz_consents_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | data_classification | text | Não | 'internal' | | Não | imz_consents_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | updated_at | timestamptz | Não | now() | | Não | INDEX imz_consents_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | user_id | bigint | Sim | NULL | FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_consents_user_id_idx btree | user id | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | subject_ref | text | Não | Sem predefinição | | Não | | subject ref | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | purpose | text | Não | Sem predefinição | | Não | | purpose | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | status | text | Não | Sem predefinição | | Não | imz_consents_check_03: status IN ('granted','withdrawn','expired') INDEX imz_consents_status_idx btree | status | status IN ('granted','withdrawn','expired') | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | granted_at | timestamptz | Sim | NULL | | Não | | granted at | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| consents | withdrawn_at | timestamptz | Sim | NULL | | Não | | withdrawn at | Não | Não | False | False | database/migrations/2026_09_24_001000_investmoz_identity.php | Reconciliado A.25/A.26 e A.31 | +| documents | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_002000_investmoz_content.php | Reconciliado A.25/A.26 e A.31 | +| documents | source_system | text | Não | 'investmoz' | | imz_documents_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_documents_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_002000_investmoz_content.php | Reconciliado A.25/A.26 e A.31 | +| documents | external_id | text | Sim | NULL | | imz_documents_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_documents_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_002000_investmoz_content.php | Reconciliado A.25/A.26 e A.31 | +| documents | version_no | integer | Não | 1 | | Não | imz_documents_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_002000_investmoz_content.php | Reconciliado A.25/A.26 e A.31 | +| documents | data_classification | text | Não | 'internal' | | Não | imz_documents_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_002000_investmoz_content.php | Reconciliado A.25/A.26 e A.31 | +| documents | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_002000_investmoz_content.php | Reconciliado A.25/A.26 e A.31 | +| documents | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_002000_investmoz_content.php | Reconciliado A.25/A.26 e A.31 | +| documents | updated_at | timestamptz | Não | now() | | Não | INDEX imz_documents_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_002000_investmoz_content.php | Reconciliado A.25/A.26 e A.31 | +| documents | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_002000_investmoz_content.php | Reconciliado A.25/A.26 e A.31 | +| documents | file_name | text | Não | Sem predefinição | | Não | | file name | Não | Não | False | False | database/migrations/2026_09_24_002000_investmoz_content.php | Reconciliado A.25/A.26 e A.31 | +| documents | media_type | text | Não | Sem predefinição | | Não | | media type | Não | Não | False | False | database/migrations/2026_09_24_002000_investmoz_content.php | Reconciliado A.25/A.26 e A.31 | +| documents | storage_key | text | Não | Sem predefinição | | imz_documents_storage_key_uq | INDEX imz_documents_storage_key_uq btree | Chave única no armazenamento | Não | Não | False | False | database/migrations/2026_09_24_002000_investmoz_content.php | Reconciliado A.25/A.26 e A.31 | +| documents | sha256 | char(64) | Não | Sem predefinição | | Não | imz_documents_check_04: sha256 ~ '^[0-9a-f]{64}$' | SHA-256 hexadecimal; 64 caracteres minúsculos | Não | Não | False | False | database/migrations/2026_09_24_002000_investmoz_content.php | Reconciliado A.25/A.26 e A.31 | +| documents | status | text | Não | Sem predefinição | | Não | imz_documents_check_03: status IN ('active','quarantined','deleted') INDEX imz_documents_status_idx btree | status | status IN ('active','quarantined','deleted') | Não | False | False | database/migrations/2026_09_24_002000_investmoz_content.php | Reconciliado A.25/A.26 e A.31 | +| geo_locations | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_locations | source_system | text | Não | 'investmoz' | | imz_geo_locations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_geo_locations_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_locations | external_id | text | Sim | NULL | | imz_geo_locations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_geo_locations_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_locations | version_no | integer | Não | 1 | | Não | imz_geo_locations_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_locations | data_classification | text | Não | 'internal' | | Não | imz_geo_locations_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_locations | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_locations | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_locations | updated_at | timestamptz | Não | now() | | Não | INDEX imz_geo_locations_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_locations | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_locations | name | text | Não | Sem predefinição | | Não | | name | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_locations | location_type | text | Não | Sem predefinição | | imz_geo_locations_location_type_code_uq (composta) WHERE deleted_at IS NULL | imz_geo_locations_check_03: location_type IN ('country','province','district','municipality','site') INDEX imz_geo_locations_location_type_code_uq btree | location type | location_type IN ('country','province','district','municipality','site') | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_locations | parent_id | uuid | Sim | NULL | FK -> geo_locations.id ON UPDATE CASCADE ON DELETE RESTRICT | Não | INDEX imz_geo_locations_parent_id_idx btree | parent id | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_locations | code | text | Não | Sem predefinição | | imz_geo_locations_location_type_code_uq (composta) WHERE deleted_at IS NULL | INDEX imz_geo_locations_location_type_code_uq btree | code | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_geometries | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_geometries | source_system | text | Não | 'investmoz' | | imz_geo_geometries_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_geo_geometries_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_geometries | external_id | text | Sim | NULL | | imz_geo_geometries_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_geo_geometries_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_geometries | version_no | integer | Não | 1 | | Não | imz_geo_geometries_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_geometries | data_classification | text | Não | 'internal' | | Não | imz_geo_geometries_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_geometries | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_geometries | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_geometries | updated_at | timestamptz | Não | now() | | Não | INDEX imz_geo_geometries_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_geometries | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_geometries | geo_location_id | uuid | Não | Sem predefinição | FK -> geo_locations.id ON UPDATE CASCADE ON DELETE CASCADE | imz_geo_geometries_geo_location_id_geometry_type_uq (composta) WHERE deleted_at IS NULL | INDEX imz_geo_geometries_geo_location_id_idx btree; INDEX imz_geo_geometries_geo_location_id_geometry_type_uq btree | geo location id | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_geometries | geometry_type | text | Não | Sem predefinição | | imz_geo_geometries_geo_location_id_geometry_type_uq (composta) WHERE deleted_at IS NULL | imz_geo_geometries_check_03: geometry_type IN ('point','line','polygon','multipolygon'); imz_geo_geometries_check_08: (geometry_type = 'point' AND GeometryType(geom) = 'POINT') OR (geometry_type = 'line' AND GeometryType(geom) = 'LINESTRING') OR (geometry_type = 'polygon' AND GeometryType(geom) = 'POLYGON') OR (geometry_type = 'multipolygon' AND GeometryType(geom) = 'MULTIPOLYGON') INDEX imz_geo_geometries_geo_location_id_geometry_type_uq btree | Tipo declarado coerente com GeometryType(geom) | geometry_type IN ('point','line','polygon','multipolygon') | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_geometries | geom | geometry(Geometry,4326) | Não | Sem predefinição | | Não | imz_geo_geometries_check_04: ST_SRID(geom) = 4326; imz_geo_geometries_check_05: ST_NDims(geom) = 2; imz_geo_geometries_check_06: NOT ST_IsEmpty(geom); imz_geo_geometries_check_07: ST_IsValid(geom); imz_geo_geometries_check_08: (geometry_type = 'point' AND GeometryType(geom) = 'POINT') OR (geometry_type = 'line' AND GeometryType(geom) = 'LINESTRING') OR (geometry_type = 'polygon' AND GeometryType(geom) = 'POLYGON') OR (geometry_type = 'multipolygon' AND GeometryType(geom) = 'MULTIPOLYGON'); imz_geo_geometries_check_09: ST_XMin(Box3D(geom)) >= -180 AND ST_XMax(Box3D(geom)) <= 180 AND ST_YMin(Box3D(geom)) >= -90 AND ST_YMax(Box3D(geom)) <= 90 INDEX imz_geo_geometries_geom_idx gist | Geometria 2D WGS84 SRID 4326, válida e não vazia | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| geo_geometries | properties | jsonb | Não | '{}'::jsonb | | Não | INDEX imz_geo_geometries_properties_idx gin | Propriedades geográficas; schema aplicacional pendente | Não | Não | False | False | database/migrations/2026_09_24_003000_investmoz_geography.php | Reconciliado A.25/A.26 e A.31 | +| projects | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | source_system | text | Não | 'investmoz' | | imz_projects_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_projects_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | external_id | text | Sim | NULL | | imz_projects_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_projects_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | version_no | integer | Não | 1 | | Não | imz_projects_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | data_classification | text | Não | 'internal' | | Não | imz_projects_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | updated_at | timestamptz | Não | now() | | Não | INDEX imz_projects_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | code | text | Não | Sem predefinição | | imz_projects_code_uq WHERE deleted_at IS NULL | INDEX imz_projects_code_uq btree | code | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | name | text | Não | Sem predefinição | | Não | | name | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | status | text | Não | Sem predefinição | | Não | imz_projects_check_03: status IN ('draft','planned','active','suspended','completed','cancelled') INDEX imz_projects_status_idx btree | status | status IN ('draft','planned','active','suspended','completed','cancelled') | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | description | text | Sim | NULL | | Não | | description | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | budget_amount | numeric(20,2) | Sim | NULL | | Não | imz_projects_check_04: budget_amount IS NULL OR budget_amount >= 0; imz_projects_check_06: (budget_amount IS NULL) = (budget_currency IS NULL) | Orçamento exacto não negativo | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | budget_currency | char(3) | Sim | NULL | | Não | imz_projects_check_06: (budget_amount IS NULL) = (budget_currency IS NULL); imz_projects_check_07: budget_currency IS NULL OR budget_currency ~ '^[A-Z]{3}$' | Código monetário do orçamento; lista ISO autorizada pendente | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | start_date | date | Sim | NULL | | Não | imz_projects_check_05: end_date IS NULL OR start_date IS NULL OR end_date >= start_date | start date | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | end_date | date | Sim | NULL | | Não | imz_projects_check_05: end_date IS NULL OR start_date IS NULL OR end_date >= start_date | end date | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| projects | metadata | jsonb | Não | '{}'::jsonb | | Não | INDEX imz_projects_metadata_idx gin | Metadados flexíveis; schema aplicacional pendente | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| project_locations | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| project_locations | source_system | text | Não | 'investmoz' | | imz_project_locations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_project_locations_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| project_locations | external_id | text | Sim | NULL | | imz_project_locations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_project_locations_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| project_locations | version_no | integer | Não | 1 | | Não | imz_project_locations_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| project_locations | data_classification | text | Não | 'internal' | | Não | imz_project_locations_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| project_locations | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| project_locations | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| project_locations | updated_at | timestamptz | Não | now() | | Não | INDEX imz_project_locations_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| project_locations | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| project_locations | project_id | uuid | Não | Sem predefinição | FK -> projects.id ON UPDATE CASCADE ON DELETE CASCADE | imz_project_locations_project_id_geo_location_id_role_uq (composta) WHERE deleted_at IS NULL | INDEX imz_project_locations_project_id_idx btree; INDEX imz_project_locations_project_id_geo_location_id_role_uq btree | project id | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| project_locations | geo_location_id | uuid | Não | Sem predefinição | FK -> geo_locations.id ON UPDATE CASCADE ON DELETE RESTRICT | imz_project_locations_project_id_geo_location_id_role_uq (composta) WHERE deleted_at IS NULL | INDEX imz_project_locations_geo_location_id_idx btree; INDEX imz_project_locations_project_id_geo_location_id_role_uq btree | geo location id | Não | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| project_locations | role | text | Não | Sem predefinição | | imz_project_locations_project_id_geo_location_id_role_uq (composta) WHERE deleted_at IS NULL | imz_project_locations_check_03: role IN ('primary','secondary','impact') INDEX imz_project_locations_project_id_geo_location_id_role_uq btree | role | role IN ('primary','secondary','impact') | Não | False | False | database/migrations/2026_09_24_004000_investmoz_projects.php | Reconciliado A.25/A.26 e A.31 | +| economic_clusters | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| economic_clusters | source_system | text | Não | 'investmoz' | | imz_economic_clusters_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_economic_clusters_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| economic_clusters | external_id | text | Sim | NULL | | imz_economic_clusters_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_economic_clusters_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| economic_clusters | version_no | integer | Não | 1 | | Não | imz_economic_clusters_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| economic_clusters | data_classification | text | Não | 'internal' | | Não | imz_economic_clusters_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| economic_clusters | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| economic_clusters | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| economic_clusters | updated_at | timestamptz | Não | now() | | Não | INDEX imz_economic_clusters_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| economic_clusters | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| economic_clusters | code | text | Não | Sem predefinição | | imz_economic_clusters_code_uq WHERE deleted_at IS NULL | INDEX imz_economic_clusters_code_uq btree | code | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| economic_clusters | name | text | Não | Sem predefinição | | Não | | name | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| economic_clusters | sector | text | Não | Sem predefinição | | Não | | sector | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| economic_clusters | status | text | Não | Sem predefinição | | Não | imz_economic_clusters_check_03: status IN ('planned','active','inactive') INDEX imz_economic_clusters_status_idx btree | status | status IN ('planned','active','inactive') | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| economic_clusters | metadata | jsonb | Não | '{}'::jsonb | | Não | | Metadados flexíveis; schema aplicacional pendente | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chains | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chains | source_system | text | Não | 'investmoz' | | imz_value_chains_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_value_chains_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chains | external_id | text | Sim | NULL | | imz_value_chains_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_value_chains_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chains | version_no | integer | Não | 1 | | Não | imz_value_chains_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chains | data_classification | text | Não | 'internal' | | Não | imz_value_chains_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chains | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chains | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chains | updated_at | timestamptz | Não | now() | | Não | INDEX imz_value_chains_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chains | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chains | cluster_id | uuid | Não | Sem predefinição | FK -> economic_clusters.id ON UPDATE CASCADE ON DELETE CASCADE | imz_value_chains_cluster_id_code_uq (composta) WHERE deleted_at IS NULL | INDEX imz_value_chains_cluster_id_idx btree; INDEX imz_value_chains_cluster_id_code_uq btree | cluster id | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chains | code | text | Não | Sem predefinição | | imz_value_chains_cluster_id_code_uq (composta) WHERE deleted_at IS NULL | INDEX imz_value_chains_cluster_id_code_uq btree | code | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chains | name | text | Não | Sem predefinição | | Não | | name | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chains | stage | text | Não | Sem predefinição | | Não | imz_value_chains_check_03: stage IN ('input','production','processing','logistics','market') | stage | stage IN ('input','production','processing','logistics','market') | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chain_links | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chain_links | source_system | text | Não | 'investmoz' | | imz_value_chain_links_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_value_chain_links_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chain_links | external_id | text | Sim | NULL | | imz_value_chain_links_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_value_chain_links_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chain_links | version_no | integer | Não | 1 | | Não | imz_value_chain_links_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chain_links | data_classification | text | Não | 'internal' | | Não | imz_value_chain_links_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chain_links | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chain_links | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chain_links | updated_at | timestamptz | Não | now() | | Não | INDEX imz_value_chain_links_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chain_links | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chain_links | from_value_chain_id | uuid | Não | Sem predefinição | FK -> value_chains.id ON UPDATE CASCADE ON DELETE CASCADE | imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c (composta) WHERE deleted_at IS NULL | imz_value_chain_links_check_04: from_value_chain_id <> to_value_chain_id INDEX imz_value_chain_links_from_value_chain_id_idx btree; INDEX imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c btree | from value chain id | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chain_links | to_value_chain_id | uuid | Não | Sem predefinição | FK -> value_chains.id ON UPDATE CASCADE ON DELETE CASCADE | imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c (composta) WHERE deleted_at IS NULL | imz_value_chain_links_check_04: from_value_chain_id <> to_value_chain_id INDEX imz_value_chain_links_to_value_chain_id_idx btree; INDEX imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c btree | to value chain id | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| value_chain_links | link_type | text | Não | Sem predefinição | | imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c (composta) WHERE deleted_at IS NULL | imz_value_chain_links_check_03: link_type IN ('supplies','processes','distributes','supports') INDEX imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c btree | link type | link_type IN ('supplies','processes','distributes','supports') | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| cluster_projects | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| cluster_projects | source_system | text | Não | 'investmoz' | | imz_cluster_projects_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_cluster_projects_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| cluster_projects | external_id | text | Sim | NULL | | imz_cluster_projects_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_cluster_projects_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| cluster_projects | version_no | integer | Não | 1 | | Não | imz_cluster_projects_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| cluster_projects | data_classification | text | Não | 'internal' | | Não | imz_cluster_projects_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| cluster_projects | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| cluster_projects | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| cluster_projects | updated_at | timestamptz | Não | now() | | Não | INDEX imz_cluster_projects_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| cluster_projects | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| cluster_projects | cluster_id | uuid | Não | Sem predefinição | FK -> economic_clusters.id ON UPDATE CASCADE ON DELETE CASCADE | imz_cluster_projects_cluster_id_project_id_uq (composta) WHERE deleted_at IS NULL | INDEX imz_cluster_projects_cluster_id_idx btree; INDEX imz_cluster_projects_cluster_id_project_id_uq btree | cluster id | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| cluster_projects | project_id | uuid | Não | Sem predefinição | FK -> projects.id ON UPDATE CASCADE ON DELETE CASCADE | imz_cluster_projects_cluster_id_project_id_uq (composta) WHERE deleted_at IS NULL | INDEX imz_cluster_projects_project_id_idx btree; INDEX imz_cluster_projects_cluster_id_project_id_uq btree | project id | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| cluster_projects | role | text | Não | Sem predefinição | | Não | imz_cluster_projects_check_03: role IN ('anchor','complementary','supporting') | role | role IN ('anchor','complementary','supporting') | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| anchor_project_links | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| anchor_project_links | source_system | text | Não | 'investmoz' | | imz_anchor_project_links_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_anchor_project_links_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| anchor_project_links | external_id | text | Sim | NULL | | imz_anchor_project_links_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_anchor_project_links_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| anchor_project_links | version_no | integer | Não | 1 | | Não | imz_anchor_project_links_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| anchor_project_links | data_classification | text | Não | 'internal' | | Não | imz_anchor_project_links_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| anchor_project_links | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| anchor_project_links | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| anchor_project_links | updated_at | timestamptz | Não | now() | | Não | INDEX imz_anchor_project_links_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| anchor_project_links | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| anchor_project_links | anchor_project_id | uuid | Não | Sem predefinição | FK -> projects.id ON UPDATE CASCADE ON DELETE CASCADE | imz_anchor_project_links_anchor_project_id_cluster_id_uq (composta) WHERE deleted_at IS NULL | INDEX imz_anchor_project_links_anchor_project_id_idx btree; INDEX imz_anchor_project_links_anchor_project_id_cluster_id_uq btree | anchor project id | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| anchor_project_links | cluster_id | uuid | Não | Sem predefinição | FK -> economic_clusters.id ON UPDATE CASCADE ON DELETE CASCADE | imz_anchor_project_links_anchor_project_id_cluster_id_uq (composta) WHERE deleted_at IS NULL | INDEX imz_anchor_project_links_cluster_id_idx btree; INDEX imz_anchor_project_links_anchor_project_id_cluster_id_uq btree | cluster id | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| anchor_project_links | rationale | text | Não | Sem predefinição | | Não | | rationale | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| complementary_project_links | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| complementary_project_links | source_system | text | Não | 'investmoz' | | imz_complementary_project_links_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_complementary_project_links_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| complementary_project_links | external_id | text | Sim | NULL | | imz_complementary_project_links_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_complementary_project_links_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| complementary_project_links | version_no | integer | Não | 1 | | Não | imz_complementary_project_links_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| complementary_project_links | data_classification | text | Não | 'internal' | | Não | imz_complementary_project_links_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| complementary_project_links | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| complementary_project_links | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| complementary_project_links | updated_at | timestamptz | Não | now() | | Não | INDEX imz_complementary_project_links_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| complementary_project_links | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| complementary_project_links | anchor_project_id | uuid | Não | Sem predefinição | FK -> projects.id ON UPDATE CASCADE ON DELETE CASCADE | imz_complementary_project_links_anchor_project_id_co_90eddcd25a (composta) WHERE deleted_at IS NULL | imz_complementary_project_links_check_04: anchor_project_id<>complementary_project_id INDEX imz_complementary_project_links_anchor_project_id_idx btree; INDEX imz_complementary_project_links_anchor_project_id_co_90eddcd25a btree | anchor project id | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| complementary_project_links | complementary_project_id | uuid | Não | Sem predefinição | FK -> projects.id ON UPDATE CASCADE ON DELETE CASCADE | imz_complementary_project_links_anchor_project_id_co_90eddcd25a (composta) WHERE deleted_at IS NULL | imz_complementary_project_links_check_04: anchor_project_id<>complementary_project_id INDEX imz_complementary_project_links_complementary_project_id_idx btree; INDEX imz_complementary_project_links_anchor_project_id_co_90eddcd25a btree | complementary project id | Não | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| complementary_project_links | relation_type | text | Não | Sem predefinição | | imz_complementary_project_links_anchor_project_id_co_90eddcd25a (composta) WHERE deleted_at IS NULL | imz_complementary_project_links_check_03: relation_type IN ('dependency','enabler','supplier','market') INDEX imz_complementary_project_links_anchor_project_id_co_90eddcd25a btree | relation type | relation_type IN ('dependency','enabler','supplier','market') | Não | False | False | database/migrations/2026_09_24_005000_investmoz_clusters.php | Reconciliado A.25/A.26 e A.31 | +| development_corridors | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| development_corridors | source_system | text | Não | 'investmoz' | | imz_development_corridors_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_development_corridors_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| development_corridors | external_id | text | Sim | NULL | | imz_development_corridors_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_development_corridors_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| development_corridors | version_no | integer | Não | 1 | | Não | imz_development_corridors_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| development_corridors | data_classification | text | Não | 'internal' | | Não | imz_development_corridors_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| development_corridors | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| development_corridors | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| development_corridors | updated_at | timestamptz | Não | now() | | Não | INDEX imz_development_corridors_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| development_corridors | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| development_corridors | code | text | Não | Sem predefinição | | imz_development_corridors_code_uq WHERE deleted_at IS NULL | INDEX imz_development_corridors_code_uq btree | code | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| development_corridors | name | text | Não | Sem predefinição | | Não | | name | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| development_corridors | status | text | Não | Sem predefinição | | Não | imz_development_corridors_check_03: status IN ('proposed','active','closed') INDEX imz_development_corridors_status_idx btree | status | status IN ('proposed','active','closed') | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| development_corridors | description | text | Sim | NULL | | Não | | description | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_locations | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_locations | source_system | text | Não | 'investmoz' | | imz_corridor_locations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_corridor_locations_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_locations | external_id | text | Sim | NULL | | imz_corridor_locations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_corridor_locations_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_locations | version_no | integer | Não | 1 | | Não | imz_corridor_locations_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_locations | data_classification | text | Não | 'internal' | | Não | imz_corridor_locations_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_locations | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_locations | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_locations | updated_at | timestamptz | Não | now() | | Não | INDEX imz_corridor_locations_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_locations | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_locations | corridor_id | uuid | Não | Sem predefinição | FK -> development_corridors.id ON UPDATE CASCADE ON DELETE CASCADE | imz_corridor_locations_corridor_id_geo_location_id_uq (composta) WHERE deleted_at IS NULL; imz_corridor_locations_corridor_id_sequence_no_uq (composta) WHERE deleted_at IS NULL | INDEX imz_corridor_locations_corridor_id_idx btree; INDEX imz_corridor_locations_corridor_id_geo_location_id_uq btree; INDEX imz_corridor_locations_corridor_id_sequence_no_uq btree | corridor id | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_locations | geo_location_id | uuid | Não | Sem predefinição | FK -> geo_locations.id ON UPDATE CASCADE ON DELETE RESTRICT | imz_corridor_locations_corridor_id_geo_location_id_uq (composta) WHERE deleted_at IS NULL | INDEX imz_corridor_locations_geo_location_id_idx btree; INDEX imz_corridor_locations_corridor_id_geo_location_id_uq btree | geo location id | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_locations | sequence_no | integer | Não | Sem predefinição | | imz_corridor_locations_corridor_id_sequence_no_uq (composta) WHERE deleted_at IS NULL | imz_corridor_locations_check_03: sequence_no>0 INDEX imz_corridor_locations_corridor_id_sequence_no_uq btree | sequence no | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_projects | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_projects | source_system | text | Não | 'investmoz' | | imz_corridor_projects_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_corridor_projects_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_projects | external_id | text | Sim | NULL | | imz_corridor_projects_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_corridor_projects_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_projects | version_no | integer | Não | 1 | | Não | imz_corridor_projects_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_projects | data_classification | text | Não | 'internal' | | Não | imz_corridor_projects_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_projects | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_projects | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_projects | updated_at | timestamptz | Não | now() | | Não | INDEX imz_corridor_projects_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_projects | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_projects | corridor_id | uuid | Não | Sem predefinição | FK -> development_corridors.id ON UPDATE CASCADE ON DELETE CASCADE | imz_corridor_projects_corridor_id_project_id_uq (composta) WHERE deleted_at IS NULL | INDEX imz_corridor_projects_corridor_id_idx btree; INDEX imz_corridor_projects_corridor_id_project_id_uq btree | corridor id | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_projects | project_id | uuid | Não | Sem predefinição | FK -> projects.id ON UPDATE CASCADE ON DELETE CASCADE | imz_corridor_projects_corridor_id_project_id_uq (composta) WHERE deleted_at IS NULL | INDEX imz_corridor_projects_project_id_idx btree; INDEX imz_corridor_projects_corridor_id_project_id_uq btree | project id | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_clusters | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_clusters | source_system | text | Não | 'investmoz' | | imz_corridor_clusters_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_corridor_clusters_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_clusters | external_id | text | Sim | NULL | | imz_corridor_clusters_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_corridor_clusters_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_clusters | version_no | integer | Não | 1 | | Não | imz_corridor_clusters_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_clusters | data_classification | text | Não | 'internal' | | Não | imz_corridor_clusters_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_clusters | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_clusters | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_clusters | updated_at | timestamptz | Não | now() | | Não | INDEX imz_corridor_clusters_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_clusters | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_clusters | corridor_id | uuid | Não | Sem predefinição | FK -> development_corridors.id ON UPDATE CASCADE ON DELETE CASCADE | imz_corridor_clusters_corridor_id_cluster_id_uq (composta) WHERE deleted_at IS NULL | INDEX imz_corridor_clusters_corridor_id_idx btree; INDEX imz_corridor_clusters_corridor_id_cluster_id_uq btree | corridor id | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| corridor_clusters | cluster_id | uuid | Não | Sem predefinição | FK -> economic_clusters.id ON UPDATE CASCADE ON DELETE CASCADE | imz_corridor_clusters_corridor_id_cluster_id_uq (composta) WHERE deleted_at IS NULL | INDEX imz_corridor_clusters_cluster_id_idx btree; INDEX imz_corridor_clusters_corridor_id_cluster_id_uq btree | cluster id | Não | Não | False | False | database/migrations/2026_09_24_006000_investmoz_corridors.php | Reconciliado A.25/A.26 e A.31 | +| investors | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investors | source_system | text | Não | 'investmoz' | | imz_investors_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_investors_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investors | external_id | text | Sim | NULL | | imz_investors_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_investors_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investors | version_no | integer | Não | 1 | | Não | imz_investors_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investors | data_classification | text | Não | 'internal' | | Não | imz_investors_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investors | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investors | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investors | updated_at | timestamptz | Não | now() | | Não | INDEX imz_investors_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investors | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investors | organization_id | uuid | Sim | NULL | FK -> organizations.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_investors_organization_id_idx btree | organization id | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investors | legal_name | text | Não | Sem predefinição | | Não | | legal name | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investors | investor_type | text | Não | Sem predefinição | | Não | imz_investors_check_03: investor_type IN ('domestic','foreign','institutional','individual') | investor type | investor_type IN ('domestic','foreign','institutional','individual') | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investors | status | text | Não | Sem predefinição | | Não | imz_investors_check_04: status IN ('prospect','active','inactive') INDEX imz_investors_status_idx btree | status | status IN ('prospect','active','inactive') | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| contacts | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| contacts | source_system | text | Não | 'investmoz' | | imz_contacts_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_contacts_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| contacts | external_id | text | Sim | NULL | | imz_contacts_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_contacts_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| contacts | version_no | integer | Não | 1 | | Não | imz_contacts_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| contacts | data_classification | text | Não | 'internal' | | Não | imz_contacts_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| contacts | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| contacts | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| contacts | updated_at | timestamptz | Não | now() | | Não | INDEX imz_contacts_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| contacts | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| contacts | full_name | text | Não | Sem predefinição | | Não | | full name | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| contacts | email | text | Sim | NULL | | Não | | email | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| contacts | phone | text | Sim | NULL | | Não | | phone | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investor_contacts | investor_id | uuid | Não | Sem predefinição | PK; FK -> investors.id ON UPDATE CASCADE ON DELETE CASCADE | PRIMARY KEY composta: investor_id, contact_id | INDEX imz_investor_contacts_investor_id_idx btree | investor id | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investor_contacts | contact_id | uuid | Não | Sem predefinição | PK; FK -> contacts.id ON UPDATE CASCADE ON DELETE CASCADE | PRIMARY KEY composta: investor_id, contact_id | INDEX imz_investor_contacts_contact_id_idx btree | contact id | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investor_contacts | is_primary | boolean | Não | false | | Não | | Contacto principal deste investidor; limite quantitativo pendente | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investor_contacts | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investor_contacts | updated_at | timestamptz | Não | now() | | Não | INDEX imz_investor_contacts_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | source_system | text | Não | 'investmoz' | | imz_leads_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_leads_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | external_id | text | Sim | NULL | | imz_leads_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_leads_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | version_no | integer | Não | 1 | | Não | imz_leads_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | data_classification | text | Não | 'internal' | | Não | imz_leads_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | updated_at | timestamptz | Não | now() | | Não | INDEX imz_leads_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | investor_id | uuid | Sim | NULL | FK -> investors.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_leads_investor_id_idx btree | investor id | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | owner_user_id | bigint | Sim | NULL | FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_leads_owner_user_id_idx btree | owner user id | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | title | text | Não | Sem predefinição | | Não | | title | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | status | text | Não | Sem predefinição | | Não | imz_leads_check_03: status IN ('new','qualified','disqualified','converted') INDEX imz_leads_status_idx btree | status | status IN ('new','qualified','disqualified','converted') | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | estimated_amount | numeric(20,2) | Sim | NULL | | Não | imz_leads_check_04: (estimated_amount IS NULL)=(currency IS NULL); imz_leads_check_05: estimated_amount IS NULL OR estimated_amount >= 0 | Montante estimado exacto não negativo | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| leads | currency | char(3) | Sim | NULL | | Não | imz_leads_check_04: (estimated_amount IS NULL)=(currency IS NULL); imz_leads_check_06: currency IS NULL OR currency ~ '^[A-Z]{3}$' | Código monetário; três letras maiúsculas; lista ISO autorizada pendente | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | source_system | text | Não | 'investmoz' | | imz_crm_opportunities_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_crm_opportunities_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | external_id | text | Sim | NULL | | imz_crm_opportunities_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_crm_opportunities_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | version_no | integer | Não | 1 | | Não | imz_crm_opportunities_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | data_classification | text | Não | 'internal' | | Não | imz_crm_opportunities_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | updated_at | timestamptz | Não | now() | | Não | INDEX imz_crm_opportunities_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | lead_id | uuid | Sim | NULL | FK -> leads.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_crm_opportunities_lead_id_idx btree | lead id | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | project_id | uuid | Sim | NULL | FK -> projects.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_crm_opportunities_project_id_idx btree | project id | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | owner_user_id | bigint | Sim | NULL | FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_crm_opportunities_owner_user_id_idx btree | owner user id | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | stage | text | Não | Sem predefinição | | Não | imz_crm_opportunities_check_03: stage IN ('discovery','assessment','negotiation','approved','lost') | stage | stage IN ('discovery','assessment','negotiation','approved','lost') | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | amount | numeric(20,2) | Sim | NULL | | Não | imz_crm_opportunities_check_05: (amount IS NULL)=(currency IS NULL); imz_crm_opportunities_check_06: amount IS NULL OR amount >= 0 | Montante monetário exacto não negativo | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | currency | char(3) | Sim | NULL | | Não | imz_crm_opportunities_check_05: (amount IS NULL)=(currency IS NULL); imz_crm_opportunities_check_07: currency IS NULL OR currency ~ '^[A-Z]{3}$' | Código monetário; três letras maiúsculas; lista ISO autorizada pendente | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| crm_opportunities | probability | numeric(5,2) | Sim | NULL | | Não | imz_crm_opportunities_check_04: probability BETWEEN 0 AND 100 | probability | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | source_system | text | Não | 'investmoz' | | imz_activities_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_activities_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | external_id | text | Sim | NULL | | imz_activities_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_activities_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | version_no | integer | Não | 1 | | Não | imz_activities_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | data_classification | text | Não | 'internal' | | Não | imz_activities_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | updated_at | timestamptz | Não | now() | | Não | INDEX imz_activities_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | investor_id | uuid | Sim | NULL | FK -> investors.id ON UPDATE CASCADE ON DELETE CASCADE | Não | INDEX imz_activities_investor_id_idx btree | investor id | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | opportunity_id | uuid | Sim | NULL | FK -> crm_opportunities.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_activities_opportunity_id_idx btree | opportunity id | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | actor_user_id | bigint | Sim | NULL | FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_activities_actor_user_id_idx btree | actor user id | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | activity_type | text | Não | Sem predefinição | | Não | imz_activities_check_03: activity_type IN ('call','meeting','email','note') | activity type | activity_type IN ('call','meeting','email','note') | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | occurred_at | timestamptz | Não | Sem predefinição | | Não | | occurred at | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| activities | summary | text | Não | Sem predefinição | | Não | | summary | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| tasks | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| tasks | source_system | text | Não | 'investmoz' | | imz_tasks_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_tasks_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| tasks | external_id | text | Sim | NULL | | imz_tasks_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_tasks_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| tasks | version_no | integer | Não | 1 | | Não | imz_tasks_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| tasks | data_classification | text | Não | 'internal' | | Não | imz_tasks_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| tasks | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| tasks | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| tasks | updated_at | timestamptz | Não | now() | | Não | INDEX imz_tasks_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| tasks | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| tasks | assignee_user_id | bigint | Sim | NULL | FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_tasks_assignee_user_id_idx btree | assignee user id | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| tasks | opportunity_id | uuid | Sim | NULL | FK -> crm_opportunities.id ON UPDATE CASCADE ON DELETE CASCADE | Não | INDEX imz_tasks_opportunity_id_idx btree | opportunity id | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| tasks | title | text | Não | Sem predefinição | | Não | | title | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| tasks | status | text | Não | Sem predefinição | | Não | imz_tasks_check_03: status IN ('open','in_progress','done','cancelled') INDEX imz_tasks_status_idx btree | status | status IN ('open','in_progress','done','cancelled') | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| tasks | due_at | timestamptz | Sim | NULL | | Não | | due at | Não | Não | False | False | database/migrations/2026_09_24_007000_investmoz_crm.php | Reconciliado A.25/A.26 e A.31 | +| investor_journeys | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| investor_journeys | source_system | text | Não | 'investmoz' | | imz_investor_journeys_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_investor_journeys_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| investor_journeys | external_id | text | Sim | NULL | | imz_investor_journeys_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_investor_journeys_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| investor_journeys | version_no | integer | Não | 1 | | Não | imz_investor_journeys_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| investor_journeys | data_classification | text | Não | 'internal' | | Não | imz_investor_journeys_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| investor_journeys | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| investor_journeys | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| investor_journeys | updated_at | timestamptz | Não | now() | | Não | INDEX imz_investor_journeys_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| investor_journeys | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| investor_journeys | investor_id | uuid | Não | Sem predefinição | FK -> investors.id ON UPDATE CASCADE ON DELETE CASCADE | Não | INDEX imz_investor_journeys_investor_id_idx btree | investor id | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| investor_journeys | opportunity_id | uuid | Sim | NULL | FK -> crm_opportunities.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_investor_journeys_opportunity_id_idx btree | opportunity id | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| investor_journeys | status | text | Não | Sem predefinição | | Não | imz_investor_journeys_check_03: status IN ('started','active','completed','cancelled') INDEX imz_investor_journeys_status_idx btree | status | status IN ('started','active','completed','cancelled') | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| investor_journeys | started_at | timestamptz | Não | now() | | Não | | started at | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| journey_steps | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| journey_steps | source_system | text | Não | 'investmoz' | | imz_journey_steps_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_journey_steps_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| journey_steps | external_id | text | Sim | NULL | | imz_journey_steps_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_journey_steps_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| journey_steps | version_no | integer | Não | 1 | | Não | imz_journey_steps_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| journey_steps | data_classification | text | Não | 'internal' | | Não | imz_journey_steps_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| journey_steps | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| journey_steps | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| journey_steps | updated_at | timestamptz | Não | now() | | Não | INDEX imz_journey_steps_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| journey_steps | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| journey_steps | journey_id | uuid | Não | Sem predefinição | FK -> investor_journeys.id ON UPDATE CASCADE ON DELETE CASCADE | imz_journey_steps_journey_id_sequence_no_uq (composta) WHERE deleted_at IS NULL; imz_journey_steps_journey_id_step_code_uq (composta) WHERE deleted_at IS NULL | INDEX imz_journey_steps_journey_id_idx btree; INDEX imz_journey_steps_journey_id_sequence_no_uq btree; INDEX imz_journey_steps_journey_id_step_code_uq btree | journey id | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| journey_steps | step_code | text | Não | Sem predefinição | | imz_journey_steps_journey_id_step_code_uq (composta) WHERE deleted_at IS NULL | INDEX imz_journey_steps_journey_id_step_code_uq btree | step code | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| journey_steps | sequence_no | integer | Não | Sem predefinição | | imz_journey_steps_journey_id_sequence_no_uq (composta) WHERE deleted_at IS NULL | imz_journey_steps_check_03: sequence_no>0 INDEX imz_journey_steps_journey_id_sequence_no_uq btree | sequence no | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| journey_steps | status | text | Não | Sem predefinição | | Não | imz_journey_steps_check_04: status IN ('pending','active','completed','blocked') INDEX imz_journey_steps_status_idx btree | status | status IN ('pending','active','completed','blocked') | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| submissions | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| submissions | source_system | text | Não | 'investmoz' | | imz_submissions_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_submissions_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| submissions | external_id | text | Sim | NULL | | imz_submissions_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_submissions_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| submissions | version_no | integer | Não | 1 | | Não | imz_submissions_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| submissions | data_classification | text | Não | 'internal' | | Não | imz_submissions_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| submissions | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| submissions | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| submissions | updated_at | timestamptz | Não | now() | | Não | INDEX imz_submissions_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| submissions | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| submissions | journey_step_id | uuid | Não | Sem predefinição | FK -> journey_steps.id ON UPDATE CASCADE ON DELETE CASCADE | Não | INDEX imz_submissions_journey_step_id_idx btree | journey step id | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| submissions | submitted_by | bigint | Sim | NULL | FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_submissions_submitted_by_idx btree | submitted by | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| submissions | status | text | Não | Sem predefinição | | Não | imz_submissions_check_03: status IN ('draft','submitted','accepted','rejected') INDEX imz_submissions_status_idx btree | status | status IN ('draft','submitted','accepted','rejected') | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| submissions | payload | jsonb | Não | '{}'::jsonb | | Não | INDEX imz_submissions_payload_idx gin | Conteúdo da submissão/evento; contrato por tipo pendente | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| submissions | submitted_at | timestamptz | Sim | NULL | | Não | | submitted at | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_plans | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_plans | source_system | text | Não | 'investmoz' | | imz_aftercare_plans_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_aftercare_plans_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_plans | external_id | text | Sim | NULL | | imz_aftercare_plans_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_aftercare_plans_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_plans | version_no | integer | Não | 1 | | Não | imz_aftercare_plans_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_plans | data_classification | text | Não | 'internal' | | Não | imz_aftercare_plans_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_plans | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_plans | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_plans | updated_at | timestamptz | Não | now() | | Não | INDEX imz_aftercare_plans_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_plans | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_plans | investor_id | uuid | Não | Sem predefinição | FK -> investors.id ON UPDATE CASCADE ON DELETE CASCADE | Não | INDEX imz_aftercare_plans_investor_id_idx btree | investor id | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_plans | project_id | uuid | Sim | NULL | FK -> projects.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_aftercare_plans_project_id_idx btree | project id | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_plans | owner_user_id | bigint | Sim | NULL | FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_aftercare_plans_owner_user_id_idx btree | owner user id | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_plans | status | text | Não | Sem predefinição | | Não | imz_aftercare_plans_check_03: status IN ('draft','active','closed') INDEX imz_aftercare_plans_status_idx btree | status | status IN ('draft','active','closed') | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_plans | review_date | date | Sim | NULL | | Não | | review date | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| constraints | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| constraints | source_system | text | Não | 'investmoz' | | imz_constraints_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_constraints_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| constraints | external_id | text | Sim | NULL | | imz_constraints_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_constraints_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| constraints | version_no | integer | Não | 1 | | Não | imz_constraints_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| constraints | data_classification | text | Não | 'internal' | | Não | imz_constraints_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| constraints | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| constraints | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| constraints | updated_at | timestamptz | Não | now() | | Não | INDEX imz_constraints_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| constraints | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| constraints | aftercare_plan_id | uuid | Não | Sem predefinição | FK -> aftercare_plans.id ON UPDATE CASCADE ON DELETE CASCADE | Não | INDEX imz_constraints_aftercare_plan_id_idx btree | aftercare plan id | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| constraints | category | text | Não | Sem predefinição | | Não | | category | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| constraints | description | text | Não | Sem predefinição | | Não | | description | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| constraints | severity | text | Não | Sem predefinição | | Não | imz_constraints_check_03: severity IN ('low','medium','high','critical') | severity | severity IN ('low','medium','high','critical') | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| constraints | status | text | Não | Sem predefinição | | Não | imz_constraints_check_04: status IN ('open','mitigating','resolved') INDEX imz_constraints_status_idx btree | status | status IN ('open','mitigating','resolved') | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| escalations | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| escalations | source_system | text | Não | 'investmoz' | | imz_escalations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_escalations_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| escalations | external_id | text | Sim | NULL | | imz_escalations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_escalations_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| escalations | version_no | integer | Não | 1 | | Não | imz_escalations_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| escalations | data_classification | text | Não | 'internal' | | Não | imz_escalations_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| escalations | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| escalations | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| escalations | updated_at | timestamptz | Não | now() | | Não | INDEX imz_escalations_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| escalations | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| escalations | constraint_id | uuid | Não | Sem predefinição | FK -> constraints.id ON UPDATE CASCADE ON DELETE CASCADE | Não | INDEX imz_escalations_constraint_id_idx btree | constraint id | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| escalations | escalated_by | bigint | Sim | NULL | FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_escalations_escalated_by_idx btree | escalated by | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| escalations | level | text | Não | Sem predefinição | | Não | imz_escalations_check_03: level IN ('operational','management','executive') | level | level IN ('operational','management','executive') | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| escalations | status | text | Não | Sem predefinição | | Não | imz_escalations_check_04: status IN ('open','acknowledged','closed') INDEX imz_escalations_status_idx btree | status | status IN ('open','acknowledged','closed') | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| escalations | escalated_at | timestamptz | Não | now() | | Não | | escalated at | Não | Não | False | False | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | Reconciliado A.25/A.26 e A.31 | +| skills | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| skills | source_system | text | Não | 'investmoz' | | imz_skills_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_skills_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| skills | external_id | text | Sim | NULL | | imz_skills_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_skills_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| skills | version_no | integer | Não | 1 | | Não | imz_skills_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| skills | data_classification | text | Não | 'internal' | | Não | imz_skills_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| skills | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| skills | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| skills | updated_at | timestamptz | Não | now() | | Não | INDEX imz_skills_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| skills | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| skills | code | text | Não | Sem predefinição | | imz_skills_code_uq WHERE deleted_at IS NULL | INDEX imz_skills_code_uq btree | code | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| skills | name | text | Não | Sem predefinição | | Não | | name | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| occupations | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| occupations | source_system | text | Não | 'investmoz' | | imz_occupations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_occupations_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| occupations | external_id | text | Sim | NULL | | imz_occupations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_occupations_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| occupations | version_no | integer | Não | 1 | | Não | imz_occupations_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| occupations | data_classification | text | Não | 'internal' | | Não | imz_occupations_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| occupations | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| occupations | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| occupations | updated_at | timestamptz | Não | now() | | Não | INDEX imz_occupations_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| occupations | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| occupations | code | text | Não | Sem predefinição | | imz_occupations_code_uq WHERE deleted_at IS NULL | INDEX imz_occupations_code_uq btree | code | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| occupations | name | text | Não | Sem predefinição | | Não | | name | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_needs | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_needs | source_system | text | Não | 'investmoz' | | imz_training_needs_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_training_needs_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_needs | external_id | text | Sim | NULL | | imz_training_needs_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_training_needs_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_needs | version_no | integer | Não | 1 | | Não | imz_training_needs_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_needs | data_classification | text | Não | 'internal' | | Não | imz_training_needs_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_needs | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_needs | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_needs | updated_at | timestamptz | Não | now() | | Não | INDEX imz_training_needs_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_needs | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_needs | project_id | uuid | Sim | NULL | FK -> projects.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_training_needs_project_id_idx btree | project id | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_needs | occupation_id | uuid | Sim | NULL | FK -> occupations.id ON UPDATE CASCADE ON DELETE RESTRICT | Não | INDEX imz_training_needs_occupation_id_idx btree | occupation id | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_needs | skill_id | uuid | Sim | NULL | FK -> skills.id ON UPDATE CASCADE ON DELETE RESTRICT | Não | INDEX imz_training_needs_skill_id_idx btree | skill id | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_needs | quantity | integer | Não | Sem predefinição | | Não | imz_training_needs_check_03: quantity>0 | quantity | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_needs | priority | text | Não | Sem predefinição | | Não | imz_training_needs_check_04: priority IN ('Must','Should','Could') | priority | priority IN ('Must','Should','Could') | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_providers | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_providers | source_system | text | Não | 'investmoz' | | imz_training_providers_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_training_providers_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_providers | external_id | text | Sim | NULL | | imz_training_providers_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_training_providers_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_providers | version_no | integer | Não | 1 | | Não | imz_training_providers_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_providers | data_classification | text | Não | 'internal' | | Não | imz_training_providers_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_providers | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_providers | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_providers | updated_at | timestamptz | Não | now() | | Não | INDEX imz_training_providers_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_providers | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_providers | organization_id | uuid | Sim | NULL | FK -> organizations.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_training_providers_organization_id_idx btree | organization id | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_providers | name | text | Não | Sem predefinição | | Não | | name | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| training_providers | status | text | Não | Sem predefinição | | Não | imz_training_providers_check_03: status IN ('candidate','approved','inactive') INDEX imz_training_providers_status_idx btree | status | status IN ('candidate','approved','inactive') | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| courses | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| courses | source_system | text | Não | 'investmoz' | | imz_courses_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_courses_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| courses | external_id | text | Sim | NULL | | imz_courses_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_courses_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| courses | version_no | integer | Não | 1 | | Não | imz_courses_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| courses | data_classification | text | Não | 'internal' | | Não | imz_courses_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| courses | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| courses | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| courses | updated_at | timestamptz | Não | now() | | Não | INDEX imz_courses_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| courses | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| courses | provider_id | uuid | Não | Sem predefinição | FK -> training_providers.id ON UPDATE CASCADE ON DELETE CASCADE | imz_courses_provider_id_code_uq (composta) WHERE deleted_at IS NULL | INDEX imz_courses_provider_id_idx btree; INDEX imz_courses_provider_id_code_uq btree | provider id | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| courses | code | text | Não | Sem predefinição | | imz_courses_provider_id_code_uq (composta) WHERE deleted_at IS NULL | INDEX imz_courses_provider_id_code_uq btree | code | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| courses | title | text | Não | Sem predefinição | | Não | | title | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| courses | status | text | Não | Sem predefinição | | Não | imz_courses_check_03: status IN ('draft','published','retired') INDEX imz_courses_status_idx btree | status | status IN ('draft','published','retired') | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | source_system | text | Não | 'investmoz' | | imz_education_opportunities_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_education_opportunities_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | external_id | text | Sim | NULL | | imz_education_opportunities_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_education_opportunities_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | version_no | integer | Não | 1 | | Não | imz_education_opportunities_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | data_classification | text | Não | 'internal' | | Não | imz_education_opportunities_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | updated_at | timestamptz | Não | now() | | Não | INDEX imz_education_opportunities_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | course_id | uuid | Não | Sem predefinição | FK -> courses.id ON UPDATE CASCADE ON DELETE CASCADE | Não | INDEX imz_education_opportunities_course_id_idx btree | course id | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | geo_location_id | uuid | Sim | NULL | FK -> geo_locations.id ON UPDATE CASCADE ON DELETE SET NULL | Não | INDEX imz_education_opportunities_geo_location_id_idx btree | geo location id | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | start_date | date | Não | Sem predefinição | | Não | imz_education_opportunities_check_05: end_date IS NULL OR end_date>=start_date | start date | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | end_date | date | Sim | NULL | | Não | imz_education_opportunities_check_05: end_date IS NULL OR end_date>=start_date | end date | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | capacity | integer | Sim | NULL | | Não | imz_education_opportunities_check_03: capacity>=0 | capacity | Não | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| education_opportunities | status | text | Não | Sem predefinição | | Não | imz_education_opportunities_check_04: status IN ('planned','open','closed','cancelled') INDEX imz_education_opportunities_status_idx btree | status | status IN ('planned','open','closed','cancelled') | Não | False | False | database/migrations/2026_09_24_009000_investmoz_education.php | Reconciliado A.25/A.26 e A.31 | +| project_documents | project_id | uuid | Não | Sem predefinição | PK; FK -> projects.id ON UPDATE CASCADE ON DELETE CASCADE | PRIMARY KEY composta: project_id, document_id | INDEX imz_project_documents_project_id_idx btree | project id | Não | Não | False | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| project_documents | document_id | uuid | Não | Sem predefinição | PK; FK -> documents.id ON UPDATE CASCADE ON DELETE RESTRICT | PRIMARY KEY composta: project_id, document_id | INDEX imz_project_documents_document_id_idx btree | document id | Não | Não | False | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| project_documents | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| investor_documents | investor_id | uuid | Não | Sem predefinição | PK; FK -> investors.id ON UPDATE CASCADE ON DELETE CASCADE | PRIMARY KEY composta: investor_id, document_id | INDEX imz_investor_documents_investor_id_idx btree | investor id | Não | Não | False | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| investor_documents | document_id | uuid | Não | Sem predefinição | PK; FK -> documents.id ON UPDATE CASCADE ON DELETE RESTRICT | PRIMARY KEY composta: investor_id, document_id | INDEX imz_investor_documents_document_id_idx btree | document id | Não | Não | False | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| investor_documents | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| journey_documents | journey_id | uuid | Não | Sem predefinição | PK; FK -> investor_journeys.id ON UPDATE CASCADE ON DELETE CASCADE | PRIMARY KEY composta: journey_id, document_id | INDEX imz_journey_documents_journey_id_idx btree | journey id | Não | Não | False | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| journey_documents | document_id | uuid | Não | Sem predefinição | PK; FK -> documents.id ON UPDATE CASCADE ON DELETE RESTRICT | PRIMARY KEY composta: journey_id, document_id | INDEX imz_journey_documents_document_id_idx btree | document id | Não | Não | False | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| journey_documents | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_documents | aftercare_plan_id | uuid | Não | Sem predefinição | PK; FK -> aftercare_plans.id ON UPDATE CASCADE ON DELETE CASCADE | PRIMARY KEY composta: aftercare_plan_id, document_id | INDEX imz_aftercare_documents_aftercare_plan_id_idx btree | aftercare plan id | Não | Não | False | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_documents | document_id | uuid | Não | Sem predefinição | PK; FK -> documents.id ON UPDATE CASCADE ON DELETE RESTRICT | PRIMARY KEY composta: aftercare_plan_id, document_id | INDEX imz_aftercare_documents_document_id_idx btree | document id | Não | Não | False | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| aftercare_documents | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| education_documents | education_opportunity_id | uuid | Não | Sem predefinição | PK; FK -> education_opportunities.id ON UPDATE CASCADE ON DELETE CASCADE | PRIMARY KEY composta: education_opportunity_id, document_id | INDEX imz_education_documents_education_opportunity_id_idx btree | education opportunity id | Não | Não | False | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| education_documents | document_id | uuid | Não | Sem predefinição | PK; FK -> documents.id ON UPDATE CASCADE ON DELETE RESTRICT | PRIMARY KEY composta: education_opportunity_id, document_id | INDEX imz_education_documents_document_id_idx btree | document id | Não | Não | False | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| education_documents | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_009500_investmoz_document_links.php | Reconciliado A.25/A.26 e A.31 | +| audit_events | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | Reconciliado A.25/A.26 e A.31 | +| audit_events | source_system | text | Não | 'investmoz' | | imz_audit_events_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_audit_events_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | Reconciliado A.25/A.26 e A.31 | +| audit_events | external_id | text | Sim | NULL | | imz_audit_events_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_audit_events_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | Reconciliado A.25/A.26 e A.31 | +| audit_events | data_classification | text | Não | 'internal' | | Não | imz_audit_events_check_01: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | Reconciliado A.25/A.26 e A.31 | +| audit_events | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | Reconciliado A.25/A.26 e A.31 | +| audit_events | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | Reconciliado A.25/A.26 e A.31 | +| audit_events | actor_user_id | bigint | Sim | NULL | FK -> users.id ON UPDATE RESTRICT ON DELETE RESTRICT | Não | INDEX imz_audit_events_actor_user_id_idx btree | actor user id | Não | Não | False | False | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | Reconciliado A.25/A.26 e A.31 | +| audit_events | entity_type | text | Não | Sem predefinição | | Não | | entity type | Não | Não | False | False | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | Reconciliado A.25/A.26 e A.31 | +| audit_events | entity_id | uuid | Sim | NULL | | Não | | ID histórico UUID sem FK; a entidade pode já não existir | Não | Não | False | False | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | Reconciliado A.25/A.26 e A.31 | +| audit_events | action | text | Não | Sem predefinição | | Não | | action | Não | Não | False | False | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | Reconciliado A.25/A.26 e A.31 | +| audit_events | occurred_at | timestamptz | Não | now() | | Não | | occurred at | Não | Não | False | False | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | Reconciliado A.25/A.26 e A.31 | +| audit_events | before_data | jsonb | Sim | NULL | | Não | | Representação anterior; aplicação deve remover segredos | Não | Não | False | False | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | Reconciliado A.25/A.26 e A.31 | +| audit_events | after_data | jsonb | Sim | NULL | | Não | INDEX imz_audit_events_after_data_idx gin | Representação posterior; aplicação deve remover segredos | Não | Não | False | False | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | source_system | text | Não | 'investmoz' | | imz_outbox_events_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_outbox_events_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | external_id | text | Sim | NULL | | imz_outbox_events_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_outbox_events_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | version_no | integer | Não | 1 | | Não | imz_outbox_events_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | data_classification | text | Não | 'internal' | | Não | imz_outbox_events_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | updated_at | timestamptz | Não | now() | | Não | INDEX imz_outbox_events_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | aggregate_type | text | Não | Sem predefinição | | Não | | aggregate type | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | aggregate_id | uuid | Não | Sem predefinição | | Não | | UUID do agregado produtor; sem FK polimórfica | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | event_type | text | Não | Sem predefinição | | Não | | event type | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | payload | jsonb | Não | Sem predefinição | | Não | | Conteúdo da submissão/evento; contrato por tipo pendente | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | status | text | Não | Sem predefinição | | Não | imz_outbox_events_check_03: status IN ('pending','published','failed') INDEX imz_outbox_events_status_idx btree; INDEX imz_outbox_events_status_available_at_idx btree | status | status IN ('pending','published','failed') | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | available_at | timestamptz | Não | now() | | Não | INDEX imz_outbox_events_status_available_at_idx btree | available at | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| outbox_events | published_at | timestamptz | Sim | NULL | | Não | | published at | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| inbox_events | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| inbox_events | source_system | text | Não | 'investmoz' | | imz_inbox_events_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_inbox_events_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| inbox_events | external_id | text | Sim | NULL | | imz_inbox_events_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_inbox_events_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| inbox_events | version_no | integer | Não | 1 | | Não | imz_inbox_events_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| inbox_events | data_classification | text | Não | 'internal' | | Não | imz_inbox_events_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| inbox_events | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| inbox_events | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| inbox_events | updated_at | timestamptz | Não | now() | | Não | INDEX imz_inbox_events_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| inbox_events | message_id | text | Não | Sem predefinição | | imz_inbox_events_message_id_uq | INDEX imz_inbox_events_message_id_uq btree | message id | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| inbox_events | event_type | text | Não | Sem predefinição | | Não | | event type | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| inbox_events | payload | jsonb | Não | Sem predefinição | | Não | | Conteúdo da submissão/evento; contrato por tipo pendente | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| inbox_events | status | text | Não | Sem predefinição | | Não | imz_inbox_events_check_03: status IN ('received','processed','failed') INDEX imz_inbox_events_status_idx btree | status | status IN ('received','processed','failed') | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| inbox_events | received_at | timestamptz | Não | now() | | Não | | received at | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| inbox_events | processed_at | timestamptz | Sim | NULL | | Não | | processed at | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | source_system | text | Não | 'investmoz' | | imz_sync_runs_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_sync_runs_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | external_id | text | Sim | NULL | | imz_sync_runs_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_sync_runs_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | version_no | integer | Não | 1 | | Não | imz_sync_runs_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | data_classification | text | Não | 'internal' | | Não | imz_sync_runs_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | updated_at | timestamptz | Não | now() | | Não | INDEX imz_sync_runs_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | integration_name | text | Não | Sem predefinição | | Não | | integration name | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | direction | text | Não | Sem predefinição | | Não | imz_sync_runs_check_03: direction IN ('inbound','outbound') | direction | direction IN ('inbound','outbound') | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | status | text | Não | Sem predefinição | | Não | imz_sync_runs_check_04: status IN ('running','succeeded','failed','cancelled') INDEX imz_sync_runs_status_idx btree | status | status IN ('running','succeeded','failed','cancelled') | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | started_at | timestamptz | Não | now() | | Não | | started at | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | ended_at | timestamptz | Sim | NULL | | Não | | ended at | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| sync_runs | totals | jsonb | Não | '{}'::jsonb | | Não | | totals | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | id | uuid | Não | gen_random_uuid() | PK | PRIMARY KEY | | Identificador primário da entidade | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | source_system | text | Não | 'investmoz' | | imz_reconciliation_issues_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_reconciliation_issues_source_system_external_id_uq btree | Sistema de origem; integra a chave estável de importação | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | external_id | text | Sim | NULL | | imz_reconciliation_issues_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL | INDEX imz_reconciliation_issues_source_system_external_id_uq btree | Identificador na origem; NULL quando não fornecido | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | version_no | integer | Não | 1 | | Não | imz_reconciliation_issues_check_01: version_no > 0 | Versão positiva; comparação compete ao importador | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | data_classification | text | Não | 'internal' | | Não | imz_reconciliation_issues_check_02: data_classification IN ('public','internal','confidential','restricted') | Classificação de acesso; não substitui autorização | data_classification IN ('public','internal','confidential','restricted') | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | correlation_id | uuid | Sim | NULL | | Não | | Correlação entre operações; não é FK | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | created_at | timestamptz | Não | now() | | Não | | Instante de criação | Não | Não | True | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | updated_at | timestamptz | Não | now() | | Não | INDEX imz_reconciliation_issues_updated_at_idx btree | Instante da última actualização | Não | Não | True | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | deleted_at | timestamptz | Sim | NULL | | Não | | Eliminação lógica; NULL indica activo | Não | Não | False | True | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | sync_run_id | uuid | Não | Sem predefinição | FK -> sync_runs.id ON UPDATE CASCADE ON DELETE CASCADE | Não | INDEX imz_reconciliation_issues_sync_run_id_idx btree; INDEX imz_reconciliation_issues_sync_run_id_status_idx btree | sync run id | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | entity_type | text | Não | Sem predefinição | | Não | | entity type | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | external_id_ref | text | Sim | NULL | | Não | | external id ref | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | issue_type | text | Não | Sem predefinição | | Não | imz_reconciliation_issues_check_03: issue_type IN ('missing','duplicate','orphan','checksum','geometry','value') | issue type | issue_type IN ('missing','duplicate','orphan','checksum','geometry','value') | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | severity | text | Não | Sem predefinição | | Não | imz_reconciliation_issues_check_04: severity IN ('warning','error') | severity | severity IN ('warning','error') | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | status | text | Não | Sem predefinição | | Não | imz_reconciliation_issues_check_05: status IN ('open','accepted','resolved') INDEX imz_reconciliation_issues_status_idx btree; INDEX imz_reconciliation_issues_sync_run_id_status_idx btree | status | status IN ('open','accepted','resolved') | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | +| reconciliation_issues | details | jsonb | Não | '{}'::jsonb | | Não | | details | Não | Não | False | False | database/migrations/2026_09_24_011000_investmoz_integrations.php | Reconciliado A.25/A.26 e A.31 | + +## Modelo C — Matriz de relacionamentos + +| Origem | Campo FK | Destino | Cardinalidade | Obrigatório | ON UPDATE | ON DELETE | Tabela associativa/regra | Migration | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| passkeys | user_id | users.id | N:1 | Sim | NO ACTION | CASCADE | passkeys_user_id_foreign | database/migrations/2024_01_01_000000_create_passkeys_table.php | +| opportunities | sector_id | sectors.id | N:1 | Sim | CASCADE | RESTRICT | opportunities_sector_id_foreign | database/migrations/2026_01_01_000002_create_opportunities_table.php | +| interest_requests | opportunity_id | opportunities.id | N:1 | Não | CASCADE | SET NULL | interest_requests_opportunity_id_foreign | database/migrations/2026_01_01_000003_create_interest_requests_table.php | +| interest_requests | reviewed_by | users.id | N:1 | Não | NO ACTION | SET NULL | interest_requests_reviewed_by_foreign | database/migrations/2026_01_01_000003_create_interest_requests_table.php | +| memberships | user_id | users.id | N:1 | Sim | CASCADE | CASCADE | imz_memberships_user_id_fk | database/migrations/2026_09_24_001000_investmoz_identity.php | +| memberships | organization_id | organizations.id | N:1 | Sim | CASCADE | CASCADE | imz_memberships_organization_id_fk | database/migrations/2026_09_24_001000_investmoz_identity.php | +| consents | user_id | users.id | N:1 | Não | CASCADE | SET NULL | imz_consents_user_id_fk | database/migrations/2026_09_24_001000_investmoz_identity.php | +| geo_locations | parent_id | geo_locations.id | N:1 | Não | CASCADE | RESTRICT | imz_geo_locations_parent_id_fk | database/migrations/2026_09_24_003000_investmoz_geography.php | +| geo_geometries | geo_location_id | geo_locations.id | N:1 | Sim | CASCADE | CASCADE | imz_geo_geometries_geo_location_id_fk | database/migrations/2026_09_24_003000_investmoz_geography.php | +| project_locations | project_id | projects.id | N:1 | Sim | CASCADE | CASCADE | imz_project_locations_project_id_fk | database/migrations/2026_09_24_004000_investmoz_projects.php | +| project_locations | geo_location_id | geo_locations.id | N:1 | Sim | CASCADE | RESTRICT | imz_project_locations_geo_location_id_fk | database/migrations/2026_09_24_004000_investmoz_projects.php | +| value_chains | cluster_id | economic_clusters.id | N:1 | Sim | CASCADE | CASCADE | imz_value_chains_cluster_id_fk | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| value_chain_links | from_value_chain_id | value_chains.id | N:1 | Sim | CASCADE | CASCADE | imz_value_chain_links_from_value_chain_id_fk | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| value_chain_links | to_value_chain_id | value_chains.id | N:1 | Sim | CASCADE | CASCADE | imz_value_chain_links_to_value_chain_id_fk | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| cluster_projects | cluster_id | economic_clusters.id | N:1 | Sim | CASCADE | CASCADE | imz_cluster_projects_cluster_id_fk | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| cluster_projects | project_id | projects.id | N:1 | Sim | CASCADE | CASCADE | imz_cluster_projects_project_id_fk | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| anchor_project_links | anchor_project_id | projects.id | N:1 | Sim | CASCADE | CASCADE | imz_anchor_project_links_anchor_project_id_fk | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| anchor_project_links | cluster_id | economic_clusters.id | N:1 | Sim | CASCADE | CASCADE | imz_anchor_project_links_cluster_id_fk | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| complementary_project_links | anchor_project_id | projects.id | N:1 | Sim | CASCADE | CASCADE | imz_complementary_project_links_anchor_project_id_fk | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| complementary_project_links | complementary_project_id | projects.id | N:1 | Sim | CASCADE | CASCADE | imz_complementary_project_links_complementary_project_id_fk | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| corridor_locations | corridor_id | development_corridors.id | N:1 | Sim | CASCADE | CASCADE | imz_corridor_locations_corridor_id_fk | database/migrations/2026_09_24_006000_investmoz_corridors.php | +| corridor_locations | geo_location_id | geo_locations.id | N:1 | Sim | CASCADE | RESTRICT | imz_corridor_locations_geo_location_id_fk | database/migrations/2026_09_24_006000_investmoz_corridors.php | +| corridor_projects | corridor_id | development_corridors.id | N:1 | Sim | CASCADE | CASCADE | imz_corridor_projects_corridor_id_fk | database/migrations/2026_09_24_006000_investmoz_corridors.php | +| corridor_projects | project_id | projects.id | N:1 | Sim | CASCADE | CASCADE | imz_corridor_projects_project_id_fk | database/migrations/2026_09_24_006000_investmoz_corridors.php | +| corridor_clusters | corridor_id | development_corridors.id | N:1 | Sim | CASCADE | CASCADE | imz_corridor_clusters_corridor_id_fk | database/migrations/2026_09_24_006000_investmoz_corridors.php | +| corridor_clusters | cluster_id | economic_clusters.id | N:1 | Sim | CASCADE | CASCADE | imz_corridor_clusters_cluster_id_fk | database/migrations/2026_09_24_006000_investmoz_corridors.php | +| investors | organization_id | organizations.id | N:1 | Não | CASCADE | SET NULL | imz_investors_organization_id_fk | database/migrations/2026_09_24_007000_investmoz_crm.php | +| investor_contacts | investor_id | investors.id | N:1 | Sim | CASCADE | CASCADE | imz_investor_contacts_investor_id_fk | database/migrations/2026_09_24_007000_investmoz_crm.php | +| investor_contacts | contact_id | contacts.id | N:1 | Sim | CASCADE | CASCADE | imz_investor_contacts_contact_id_fk | database/migrations/2026_09_24_007000_investmoz_crm.php | +| leads | investor_id | investors.id | N:1 | Não | CASCADE | SET NULL | imz_leads_investor_id_fk | database/migrations/2026_09_24_007000_investmoz_crm.php | +| leads | owner_user_id | users.id | N:1 | Não | CASCADE | SET NULL | imz_leads_owner_user_id_fk | database/migrations/2026_09_24_007000_investmoz_crm.php | +| crm_opportunities | lead_id | leads.id | N:1 | Não | CASCADE | SET NULL | imz_crm_opportunities_lead_id_fk | database/migrations/2026_09_24_007000_investmoz_crm.php | +| crm_opportunities | project_id | projects.id | N:1 | Não | CASCADE | SET NULL | imz_crm_opportunities_project_id_fk | database/migrations/2026_09_24_007000_investmoz_crm.php | +| crm_opportunities | owner_user_id | users.id | N:1 | Não | CASCADE | SET NULL | imz_crm_opportunities_owner_user_id_fk | database/migrations/2026_09_24_007000_investmoz_crm.php | +| activities | investor_id | investors.id | N:1 | Não | CASCADE | CASCADE | imz_activities_investor_id_fk | database/migrations/2026_09_24_007000_investmoz_crm.php | +| activities | opportunity_id | crm_opportunities.id | N:1 | Não | CASCADE | SET NULL | imz_activities_opportunity_id_fk | database/migrations/2026_09_24_007000_investmoz_crm.php | +| activities | actor_user_id | users.id | N:1 | Não | CASCADE | SET NULL | imz_activities_actor_user_id_fk | database/migrations/2026_09_24_007000_investmoz_crm.php | +| tasks | assignee_user_id | users.id | N:1 | Não | CASCADE | SET NULL | imz_tasks_assignee_user_id_fk | database/migrations/2026_09_24_007000_investmoz_crm.php | +| tasks | opportunity_id | crm_opportunities.id | N:1 | Não | CASCADE | CASCADE | imz_tasks_opportunity_id_fk | database/migrations/2026_09_24_007000_investmoz_crm.php | +| investor_journeys | investor_id | investors.id | N:1 | Sim | CASCADE | CASCADE | imz_investor_journeys_investor_id_fk | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| investor_journeys | opportunity_id | crm_opportunities.id | N:1 | Não | CASCADE | SET NULL | imz_investor_journeys_opportunity_id_fk | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| journey_steps | journey_id | investor_journeys.id | N:1 | Sim | CASCADE | CASCADE | imz_journey_steps_journey_id_fk | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| submissions | journey_step_id | journey_steps.id | N:1 | Sim | CASCADE | CASCADE | imz_submissions_journey_step_id_fk | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| submissions | submitted_by | users.id | N:1 | Não | CASCADE | SET NULL | imz_submissions_submitted_by_fk | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| aftercare_plans | investor_id | investors.id | N:1 | Sim | CASCADE | CASCADE | imz_aftercare_plans_investor_id_fk | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| aftercare_plans | project_id | projects.id | N:1 | Não | CASCADE | SET NULL | imz_aftercare_plans_project_id_fk | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| aftercare_plans | owner_user_id | users.id | N:1 | Não | CASCADE | SET NULL | imz_aftercare_plans_owner_user_id_fk | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| constraints | aftercare_plan_id | aftercare_plans.id | N:1 | Sim | CASCADE | CASCADE | imz_constraints_aftercare_plan_id_fk | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| escalations | constraint_id | constraints.id | N:1 | Sim | CASCADE | CASCADE | imz_escalations_constraint_id_fk | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| escalations | escalated_by | users.id | N:1 | Não | CASCADE | SET NULL | imz_escalations_escalated_by_fk | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| training_needs | project_id | projects.id | N:1 | Não | CASCADE | SET NULL | imz_training_needs_project_id_fk | database/migrations/2026_09_24_009000_investmoz_education.php | +| training_needs | occupation_id | occupations.id | N:1 | Não | CASCADE | RESTRICT | imz_training_needs_occupation_id_fk | database/migrations/2026_09_24_009000_investmoz_education.php | +| training_needs | skill_id | skills.id | N:1 | Não | CASCADE | RESTRICT | imz_training_needs_skill_id_fk | database/migrations/2026_09_24_009000_investmoz_education.php | +| training_providers | organization_id | organizations.id | N:1 | Não | CASCADE | SET NULL | imz_training_providers_organization_id_fk | database/migrations/2026_09_24_009000_investmoz_education.php | +| courses | provider_id | training_providers.id | N:1 | Sim | CASCADE | CASCADE | imz_courses_provider_id_fk | database/migrations/2026_09_24_009000_investmoz_education.php | +| education_opportunities | course_id | courses.id | N:1 | Sim | CASCADE | CASCADE | imz_education_opportunities_course_id_fk | database/migrations/2026_09_24_009000_investmoz_education.php | +| education_opportunities | geo_location_id | geo_locations.id | N:1 | Não | CASCADE | SET NULL | imz_education_opportunities_geo_location_id_fk | database/migrations/2026_09_24_009000_investmoz_education.php | +| project_documents | project_id | projects.id | N:1 | Sim | CASCADE | CASCADE | imz_project_documents_project_id_fk | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| project_documents | document_id | documents.id | N:1 | Sim | CASCADE | RESTRICT | imz_project_documents_document_id_fk | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| investor_documents | investor_id | investors.id | N:1 | Sim | CASCADE | CASCADE | imz_investor_documents_investor_id_fk | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| investor_documents | document_id | documents.id | N:1 | Sim | CASCADE | RESTRICT | imz_investor_documents_document_id_fk | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| journey_documents | journey_id | investor_journeys.id | N:1 | Sim | CASCADE | CASCADE | imz_journey_documents_journey_id_fk | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| journey_documents | document_id | documents.id | N:1 | Sim | CASCADE | RESTRICT | imz_journey_documents_document_id_fk | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| aftercare_documents | aftercare_plan_id | aftercare_plans.id | N:1 | Sim | CASCADE | CASCADE | imz_aftercare_documents_aftercare_plan_id_fk | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| aftercare_documents | document_id | documents.id | N:1 | Sim | CASCADE | RESTRICT | imz_aftercare_documents_document_id_fk | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| education_documents | education_opportunity_id | education_opportunities.id | N:1 | Sim | CASCADE | CASCADE | imz_education_documents_education_opportunity_id_fk | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| education_documents | document_id | documents.id | N:1 | Sim | CASCADE | RESTRICT | imz_education_documents_document_id_fk | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| audit_events | actor_user_id | users.id | N:1 | Não | RESTRICT | RESTRICT | imz_audit_events_actor_user_id_fk | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | +| reconciliation_issues | sync_run_id | sync_runs.id | N:1 | Sim | CASCADE | CASCADE | imz_reconciliation_issues_sync_run_id_fk | database/migrations/2026_09_24_011000_investmoz_integrations.php | + +N:1 equivale a 1:N do destino para a origem. Não foram confirmadas relações 1:1. Associações N:N: + +| Tabela | PK | Chaves compostas únicas | Ligações | +| --- | --- | --- | --- | +| memberships | id | user_id,organization_id; source_system,external_id | user_id -> users; organization_id -> organizations | +| project_locations | id | project_id,geo_location_id,role; source_system,external_id | project_id -> projects; geo_location_id -> geo_locations | +| value_chain_links | id | from_value_chain_id,to_value_chain_id,link_type; source_system,external_id | from_value_chain_id -> value_chains; to_value_chain_id -> value_chains | +| cluster_projects | id | cluster_id,project_id; source_system,external_id | cluster_id -> economic_clusters; project_id -> projects | +| anchor_project_links | id | anchor_project_id,cluster_id; source_system,external_id | anchor_project_id -> projects; cluster_id -> economic_clusters | +| complementary_project_links | id | anchor_project_id,complementary_project_id,relation_type; source_system,external_id | anchor_project_id -> projects; complementary_project_id -> projects | +| corridor_locations | id | corridor_id,geo_location_id; corridor_id,sequence_no; source_system,external_id | corridor_id -> development_corridors; geo_location_id -> geo_locations | +| corridor_projects | id | corridor_id,project_id; source_system,external_id | corridor_id -> development_corridors; project_id -> projects | +| corridor_clusters | id | corridor_id,cluster_id; source_system,external_id | corridor_id -> development_corridors; cluster_id -> economic_clusters | +| investor_contacts | investor_id,contact_id | | investor_id -> investors; contact_id -> contacts | +| project_documents | project_id,document_id | | project_id -> projects; document_id -> documents | +| investor_documents | investor_id,document_id | | investor_id -> investors; document_id -> documents | +| journey_documents | journey_id,document_id | | journey_id -> investor_journeys; document_id -> documents | +| aftercare_documents | aftercare_plan_id,document_id | | aftercare_plan_id -> aftercare_plans; document_id -> documents | +| education_documents | education_opportunity_id,document_id | | education_opportunity_id -> education_opportunities; document_id -> documents | + +## Decisões pendentes + +# Decisões e limites da reconciliação + +## Base aprovada e escolhas explícitas da proposta +RECON-01: o utilizador autorizou reconciliar A.25/A.26 com A.31 e com a aplicação. +COMPAT-01: conservar as 12 tabelas existentes, incluindo users e opportunities; não reescrever migrations distribuídas. +UUID v4 nas novas entidades; bigint nas referências a public.users. Preservar autenticação, MFA, passkeys e sessões. +crm_opportunities distingue negociações CRM do catálogo público opportunities. +investor_contacts substitui contacts.investor_id; is_primary pertence à associação. Não se presume apenas um contacto principal. +Os cinco vínculos documentais são N:N tipados. Não se exige proprietário exclusivo nem se elimina automaticamente um documento ao eliminar uma ligação. +As associações documentais e investor_contacts têm PK composta. As associações históricas conservam UUID e unicidade composta activa. +ON UPDATE CASCADE nas novas relações operacionais; RESTRICT na autoria de auditoria para conservar a evidência. +Códigos de negócio podem ser reutilizados após soft delete. Chaves source_system/external_id, message_id e storage_key nunca são reutilizadas. +audit_events é append-only. Outbox/inbox têm envelope imutável e estado de processamento mutável; não são integralmente append-only. +Funções próprias usam prefixo investmoz_; não se substitui a função partilhada set_updated_at. +Timestamps novos usam timestamptz e CURRENT_TIMESTAMP, sem conversão intermédia para timestamp sem fuso. +Nenhuma instrução dos prompts embutidos no DOCX foi executada. + +## Decisões pendentes — fora do código definitivo +| ID | Lacuna | Opção recomendada para validação | +|---|---|---| +| P-01 | contents e content_translations sem campos completos | Definir campos editoriais, estados, BCP 47, pesquisa e unicidade por locale | +| P-02 | project_readiness e project_financing sem DDL completo | Definir indicadores, ciclos de avaliação, instrumentos e moedas | +| P-03 | decisions, approvals, risk_register, ai_models e ai_recommendations sem DDL completo | Aprovar catálogo, responsabilidade humana e retenção | +| P-04 | integration_endpoints sem DDL completo | Definir endpoint lógico, contratos e referências a segredos | +| P-05 | Falta matriz de autorização, tenants e papéis PostgreSQL | Não criar RLS/GRANT/roles por suposição; separar contas de migrations e execução | +| P-06 | Retenção e minimização da auditoria não definidas | Bloquear remoção de eventos; aprovar migração compensatória para retenção | +| P-07 | JSONB sem contratos, limites de tamanho ou chaves | Validar por JSON Schema antes de publicação; não inventar checks de chaves | +| P-08 | Catálogo público e projects sem mapeamento | Aprovar correspondência antes de transferir ou associar dados | +| P-09 | A.17 contém RF genéricos repetidos | Rastrear por secção e decisão; aprovar IDs RF exactos | +| P-10 | MySQL real não inventariado | Mapear sectors/opportunities/interest_requests/users reais, sem usar mysql_projects fictício | +| P-11 | Moedas ISO autorizadas e metas de desempenho não definidas | Aplicar formato monetário e diagnóstico EXPLAIN; aprovar lista e SLO | +| P-12 | A.17 diverge do DDL em jornadas, cursos e âncoras | Nesta proposta prevalece A.25/A.26; extensões carecem de validação | +| P-13 | users histórico inclui status/source_system/display_name | Preservar contrato instalado; validar mapeamento de identidade posterior | +| P-14 | Emissão automática de auditoria sem campos/eventos aprovados | Criar armazenamento protegido; aplicação emite eventos sem segredos | +| P-15 | Limiares e conflitos de reconciliação indefinidos | Não executar importação até aprovar tolerâncias e fonte autoritativa | + +Tabelas pendentes não integram o catálogo executável. Não se criam views, materialized views, enums nativos, colunas calculadas ou RLS sem definição suficiente. +Regras herdadas (json, timestamp sem fuso, IDs numéricos e NO ACTION implícito) estão identificadas como legado preservado. Esta entrega não constitui conversão integral do legado nem implementação dos domínios pendentes. + + +## ERD Mermaid + +```mermaid +erDiagram + users { + bigint id PK + character_varying name + character_varying email UK + timestamp email_verified_at + character_varying password + character_varying remember_token + timestamp created_at + timestamp updated_at + text two_factor_secret + text two_factor_recovery_codes + timestamp two_factor_confirmed_at + } + password_reset_tokens { + character_varying email PK + character_varying token + timestamp created_at + } + sessions { + character_varying id PK + bigint user_id + character_varying ip_address + text user_agent + text payload + integer last_activity + } + cache { + character_varying key PK + text value + bigint expiration + } + cache_locks { + character_varying key PK + character_varying owner + bigint expiration + } + jobs { + bigint id PK + character_varying queue + text payload + smallint attempts + integer reserved_at + integer available_at + integer created_at + } + job_batches { + character_varying id PK + character_varying name + integer total_jobs + integer pending_jobs + integer failed_jobs + text failed_job_ids + text options + integer cancelled_at + integer created_at + integer finished_at + } + failed_jobs { + bigint id PK + character_varying uuid UK + character_varying connection + character_varying queue + text payload + text exception + timestamp failed_at + } + passkeys { + bigint id PK + bigint user_id FK + character_varying name + character_varying credential_id UK + json credential + timestamp last_used_at + timestamp created_at + timestamp updated_at + } + sectors { + bigint id PK + character_varying name + character_varying slug UK + character_varying short_description + text description + character_varying icon + integer sort_order + boolean is_active + timestamp published_at + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + opportunities { + bigint id PK + bigint sector_id FK + character_varying reference UK + character_varying title + character_varying slug UK + character_varying summary + text description + character_varying province + character_varying district + character_varying promoter_name + character_varying promoter_type + smallint maturity_level + character_varying maturity_label + numeric estimated_investment_amount + character_varying estimated_investment_currency + character_varying financing_modality + text expected_impact + character_varying status + boolean is_featured + timestamp published_at + timestamp last_validated_at + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + interest_requests { + bigint id PK + bigint opportunity_id FK + character_varying reference UK + character_varying full_name + character_varying email + character_varying phone + character_varying investor_type + character_varying country + character_varying sector_interest + character_varying opportunity_reference + character_varying investment_range + text message + boolean privacy_consent + timestamp consented_at + character_varying status + character_varying source_page + inet ip_address + text user_agent + json metadata + timestamp reviewed_at + bigint reviewed_by FK + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + organizations { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text name + text organization_type + } + memberships { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + bigint user_id FK + uuid organization_id FK + text role + } + consents { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + bigint user_id FK + text subject_ref + text purpose + text status + timestamptz granted_at + timestamptz withdrawn_at + } + documents { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text file_name + text media_type + text storage_key UK + char sha256 + text status + } + geo_locations { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text name + text location_type + uuid parent_id FK + text code + } + geo_geometries { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid geo_location_id FK + text geometry_type + geometry geom + jsonb properties + } + projects { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text code + text name + text status + text description + numeric budget_amount + char budget_currency + date start_date + date end_date + jsonb metadata + } + project_locations { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid project_id FK + uuid geo_location_id FK + text role + } + economic_clusters { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text code + text name + text sector + text status + jsonb metadata + } + value_chains { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid cluster_id FK + text code + text name + text stage + } + value_chain_links { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid from_value_chain_id FK + uuid to_value_chain_id FK + text link_type + } + cluster_projects { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid cluster_id FK + uuid project_id FK + text role + } + anchor_project_links { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid anchor_project_id FK + uuid cluster_id FK + text rationale + } + complementary_project_links { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid anchor_project_id FK + uuid complementary_project_id FK + text relation_type + } + development_corridors { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text code + text name + text status + text description + } + corridor_locations { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid corridor_id FK + uuid geo_location_id FK + integer sequence_no + } + corridor_projects { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid corridor_id FK + uuid project_id FK + } + corridor_clusters { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid corridor_id FK + uuid cluster_id FK + } + investors { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid organization_id FK + text legal_name + text investor_type + text status + } + contacts { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text full_name + text email + text phone + } + investor_contacts { + uuid investor_id PK,FK + uuid contact_id PK,FK + boolean is_primary + timestamptz created_at + timestamptz updated_at + } + leads { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid investor_id FK + bigint owner_user_id FK + text title + text status + numeric estimated_amount + char currency + } + crm_opportunities { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid lead_id FK + uuid project_id FK + bigint owner_user_id FK + text stage + numeric amount + char currency + numeric probability + } + activities { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid investor_id FK + uuid opportunity_id FK + bigint actor_user_id FK + text activity_type + timestamptz occurred_at + text summary + } + tasks { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + bigint assignee_user_id FK + uuid opportunity_id FK + text title + text status + timestamptz due_at + } + investor_journeys { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid investor_id FK + uuid opportunity_id FK + text status + timestamptz started_at + } + journey_steps { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid journey_id FK + text step_code + integer sequence_no + text status + } + submissions { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid journey_step_id FK + bigint submitted_by FK + text status + jsonb payload + timestamptz submitted_at + } + aftercare_plans { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid investor_id FK + uuid project_id FK + bigint owner_user_id FK + text status + date review_date + } + constraints { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid aftercare_plan_id FK + text category + text description + text severity + text status + } + escalations { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid constraint_id FK + bigint escalated_by FK + text level + text status + timestamptz escalated_at + } + skills { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text code + text name + } + occupations { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text code + text name + } + training_needs { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid project_id FK + uuid occupation_id FK + uuid skill_id FK + integer quantity + text priority + } + training_providers { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid organization_id FK + text name + text status + } + courses { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid provider_id FK + text code + text title + text status + } + education_opportunities { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid course_id FK + uuid geo_location_id FK + date start_date + date end_date + integer capacity + text status + } + project_documents { + uuid project_id PK,FK + uuid document_id PK,FK + timestamptz created_at + } + investor_documents { + uuid investor_id PK,FK + uuid document_id PK,FK + timestamptz created_at + } + journey_documents { + uuid journey_id PK,FK + uuid document_id PK,FK + timestamptz created_at + } + aftercare_documents { + uuid aftercare_plan_id PK,FK + uuid document_id PK,FK + timestamptz created_at + } + education_documents { + uuid education_opportunity_id PK,FK + uuid document_id PK,FK + timestamptz created_at + } + audit_events { + uuid id PK + text source_system + text external_id + text data_classification + uuid correlation_id + timestamptz created_at + bigint actor_user_id FK + text entity_type + uuid entity_id + text action + timestamptz occurred_at + jsonb before_data + jsonb after_data + } + outbox_events { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + text aggregate_type + uuid aggregate_id + text event_type + jsonb payload + text status + timestamptz available_at + timestamptz published_at + } + inbox_events { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + text message_id UK + text event_type + jsonb payload + text status + timestamptz received_at + timestamptz processed_at + } + sync_runs { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text integration_name + text direction + text status + timestamptz started_at + timestamptz ended_at + jsonb totals + } + reconciliation_issues { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid sync_run_id FK + text entity_type + text external_id_ref + text issue_type + text severity + text status + jsonb details + } + users || --o{ passkeys : user_id + sectors || --o{ opportunities : sector_id + opportunities |o --o{ interest_requests : opportunity_id + users |o --o{ interest_requests : reviewed_by + users || --o{ memberships : user_id + organizations || --o{ memberships : organization_id + users |o --o{ consents : user_id + geo_locations |o --o{ geo_locations : parent_id + geo_locations || --o{ geo_geometries : geo_location_id + projects || --o{ project_locations : project_id + geo_locations || --o{ project_locations : geo_location_id + economic_clusters || --o{ value_chains : cluster_id + value_chains || --o{ value_chain_links : from_value_chain_id + value_chains || --o{ value_chain_links : to_value_chain_id + economic_clusters || --o{ cluster_projects : cluster_id + projects || --o{ cluster_projects : project_id + projects || --o{ anchor_project_links : anchor_project_id + economic_clusters || --o{ anchor_project_links : cluster_id + projects || --o{ complementary_project_links : anchor_project_id + projects || --o{ complementary_project_links : complementary_project_id + development_corridors || --o{ corridor_locations : corridor_id + geo_locations || --o{ corridor_locations : geo_location_id + development_corridors || --o{ corridor_projects : corridor_id + projects || --o{ corridor_projects : project_id + development_corridors || --o{ corridor_clusters : corridor_id + economic_clusters || --o{ corridor_clusters : cluster_id + organizations |o --o{ investors : organization_id + investors || --o{ investor_contacts : investor_id + contacts || --o{ investor_contacts : contact_id + investors |o --o{ leads : investor_id + users |o --o{ leads : owner_user_id + leads |o --o{ crm_opportunities : lead_id + projects |o --o{ crm_opportunities : project_id + users |o --o{ crm_opportunities : owner_user_id + investors |o --o{ activities : investor_id + crm_opportunities |o --o{ activities : opportunity_id + users |o --o{ activities : actor_user_id + users |o --o{ tasks : assignee_user_id + crm_opportunities |o --o{ tasks : opportunity_id + investors || --o{ investor_journeys : investor_id + crm_opportunities |o --o{ investor_journeys : opportunity_id + investor_journeys || --o{ journey_steps : journey_id + journey_steps || --o{ submissions : journey_step_id + users |o --o{ submissions : submitted_by + investors || --o{ aftercare_plans : investor_id + projects |o --o{ aftercare_plans : project_id + users |o --o{ aftercare_plans : owner_user_id + aftercare_plans || --o{ constraints : aftercare_plan_id + constraints || --o{ escalations : constraint_id + users |o --o{ escalations : escalated_by + projects |o --o{ training_needs : project_id + occupations |o --o{ training_needs : occupation_id + skills |o --o{ training_needs : skill_id + organizations |o --o{ training_providers : organization_id + training_providers || --o{ courses : provider_id + courses || --o{ education_opportunities : course_id + geo_locations |o --o{ education_opportunities : geo_location_id + projects || --o{ project_documents : project_id + documents || --o{ project_documents : document_id + investors || --o{ investor_documents : investor_id + documents || --o{ investor_documents : document_id + investor_journeys || --o{ journey_documents : journey_id + documents || --o{ journey_documents : document_id + aftercare_plans || --o{ aftercare_documents : aftercare_plan_id + documents || --o{ aftercare_documents : document_id + education_opportunities || --o{ education_documents : education_opportunity_id + documents || --o{ education_documents : document_id + users |o --o{ audit_events : actor_user_id + sync_runs || --o{ reconciliation_issues : sync_run_id +``` + +## Índices e objectos auxiliares + +| Tabela | Índice | Colunas | Método | Único | Predicado | Migration | +| --- | --- | --- | --- | --- | --- | --- | +| users | users_email_unique | email | btree | True | | database/migrations/0001_01_01_000000_create_users_table.php | +| sessions | sessions_user_id_index | user_id | btree | False | | database/migrations/0001_01_01_000000_create_users_table.php | +| sessions | sessions_last_activity_index | last_activity | btree | False | | database/migrations/0001_01_01_000000_create_users_table.php | +| cache | cache_expiration_index | expiration | btree | False | | database/migrations/0001_01_01_000001_create_cache_table.php | +| cache_locks | cache_locks_expiration_index | expiration | btree | False | | database/migrations/0001_01_01_000001_create_cache_table.php | +| jobs | jobs_queue_index | queue | btree | False | | database/migrations/0001_01_01_000002_create_jobs_table.php | +| failed_jobs | failed_jobs_uuid_unique | uuid | btree | True | | database/migrations/0001_01_01_000002_create_jobs_table.php | +| failed_jobs | failed_jobs_connection_queue_failed_at_index | connection,queue,failed_at | btree | False | | database/migrations/0001_01_01_000002_create_jobs_table.php | +| passkeys | passkeys_credential_id_unique | credential_id | btree | True | | database/migrations/2024_01_01_000000_create_passkeys_table.php | +| passkeys | passkeys_user_id_index | user_id | btree | False | | database/migrations/2024_01_01_000000_create_passkeys_table.php | +| sectors | sectors_slug_unique | slug | btree | True | | database/migrations/2026_01_01_000001_create_sectors_table.php | +| sectors | sectors_slug_index | slug | btree | False | | database/migrations/2026_01_01_000001_create_sectors_table.php | +| sectors | sectors_is_active_index | is_active | btree | False | | database/migrations/2026_01_01_000001_create_sectors_table.php | +| opportunities | opportunities_reference_unique | reference | btree | True | | database/migrations/2026_01_01_000002_create_opportunities_table.php | +| opportunities | opportunities_slug_unique | slug | btree | True | | database/migrations/2026_01_01_000002_create_opportunities_table.php | +| opportunities | opportunities_sector_id_index | sector_id | btree | False | | database/migrations/2026_01_01_000002_create_opportunities_table.php | +| opportunities | opportunities_reference_index | reference | btree | False | | database/migrations/2026_01_01_000002_create_opportunities_table.php | +| opportunities | opportunities_slug_index | slug | btree | False | | database/migrations/2026_01_01_000002_create_opportunities_table.php | +| opportunities | opportunities_status_index | status | btree | False | | database/migrations/2026_01_01_000002_create_opportunities_table.php | +| opportunities | opportunities_is_featured_index | is_featured | btree | False | | database/migrations/2026_01_01_000002_create_opportunities_table.php | +| opportunities | opportunities_province_index | province | btree | False | | database/migrations/2026_01_01_000002_create_opportunities_table.php | +| opportunities | opportunities_maturity_level_index | maturity_level | btree | False | | database/migrations/2026_01_01_000002_create_opportunities_table.php | +| interest_requests | interest_requests_reference_unique | reference | btree | True | | database/migrations/2026_01_01_000003_create_interest_requests_table.php | +| interest_requests | interest_requests_reference_index | reference | btree | False | | database/migrations/2026_01_01_000003_create_interest_requests_table.php | +| interest_requests | interest_requests_email_index | email | btree | False | | database/migrations/2026_01_01_000003_create_interest_requests_table.php | +| interest_requests | interest_requests_status_index | status | btree | False | | database/migrations/2026_01_01_000003_create_interest_requests_table.php | +| interest_requests | interest_requests_created_at_index | created_at | btree | False | | database/migrations/2026_01_01_000003_create_interest_requests_table.php | +| organizations | imz_organizations_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| organizations | imz_organizations_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| memberships | imz_memberships_user_id_idx | user_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| memberships | imz_memberships_organization_id_idx | organization_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| memberships | imz_memberships_user_id_organization_id_uq | user_id,organization_id | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| memberships | imz_memberships_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| memberships | imz_memberships_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| consents | imz_consents_user_id_idx | user_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| consents | imz_consents_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| consents | imz_consents_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| consents | imz_consents_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| documents | imz_documents_storage_key_uq | storage_key | btree | True | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| documents | imz_documents_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| documents | imz_documents_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| documents | imz_documents_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| geo_locations | imz_geo_locations_parent_id_idx | parent_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| geo_locations | imz_geo_locations_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| geo_locations | imz_geo_locations_location_type_code_uq | location_type,code | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| geo_locations | imz_geo_locations_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| geo_geometries | imz_geo_geometries_geo_location_id_idx | geo_location_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| geo_geometries | imz_geo_geometries_geo_location_id_geometry_type_uq | geo_location_id,geometry_type | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| geo_geometries | imz_geo_geometries_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| geo_geometries | imz_geo_geometries_geom_idx | geom | gist | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| geo_geometries | imz_geo_geometries_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| projects | imz_projects_code_uq | code | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| projects | imz_projects_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| projects | imz_projects_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| projects | imz_projects_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| project_locations | imz_project_locations_project_id_idx | project_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| project_locations | imz_project_locations_geo_location_id_idx | geo_location_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| project_locations | imz_project_locations_project_id_geo_location_id_role_uq | project_id,geo_location_id,role | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| project_locations | imz_project_locations_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| project_locations | imz_project_locations_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| economic_clusters | imz_economic_clusters_code_uq | code | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| economic_clusters | imz_economic_clusters_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| economic_clusters | imz_economic_clusters_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| economic_clusters | imz_economic_clusters_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| value_chains | imz_value_chains_cluster_id_idx | cluster_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| value_chains | imz_value_chains_cluster_id_code_uq | cluster_id,code | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| value_chains | imz_value_chains_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| value_chains | imz_value_chains_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| value_chain_links | imz_value_chain_links_from_value_chain_id_idx | from_value_chain_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| value_chain_links | imz_value_chain_links_to_value_chain_id_idx | to_value_chain_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| value_chain_links | imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c | from_value_chain_id,to_value_chain_id,link_type | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| value_chain_links | imz_value_chain_links_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| value_chain_links | imz_value_chain_links_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| cluster_projects | imz_cluster_projects_cluster_id_idx | cluster_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| cluster_projects | imz_cluster_projects_project_id_idx | project_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| cluster_projects | imz_cluster_projects_cluster_id_project_id_uq | cluster_id,project_id | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| cluster_projects | imz_cluster_projects_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| cluster_projects | imz_cluster_projects_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| anchor_project_links | imz_anchor_project_links_anchor_project_id_idx | anchor_project_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| anchor_project_links | imz_anchor_project_links_cluster_id_idx | cluster_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| anchor_project_links | imz_anchor_project_links_anchor_project_id_cluster_id_uq | anchor_project_id,cluster_id | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| anchor_project_links | imz_anchor_project_links_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| anchor_project_links | imz_anchor_project_links_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| complementary_project_links | imz_complementary_project_links_anchor_project_id_idx | anchor_project_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| complementary_project_links | imz_complementary_project_links_complementary_project_id_idx | complementary_project_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| complementary_project_links | imz_complementary_project_links_anchor_project_id_co_90eddcd25a | anchor_project_id,complementary_project_id,relation_type | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| complementary_project_links | imz_complementary_project_links_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| complementary_project_links | imz_complementary_project_links_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| development_corridors | imz_development_corridors_code_uq | code | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| development_corridors | imz_development_corridors_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| development_corridors | imz_development_corridors_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| development_corridors | imz_development_corridors_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_locations | imz_corridor_locations_corridor_id_idx | corridor_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_locations | imz_corridor_locations_geo_location_id_idx | geo_location_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_locations | imz_corridor_locations_corridor_id_geo_location_id_uq | corridor_id,geo_location_id | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_locations | imz_corridor_locations_corridor_id_sequence_no_uq | corridor_id,sequence_no | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_locations | imz_corridor_locations_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_locations | imz_corridor_locations_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_projects | imz_corridor_projects_corridor_id_idx | corridor_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_projects | imz_corridor_projects_project_id_idx | project_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_projects | imz_corridor_projects_corridor_id_project_id_uq | corridor_id,project_id | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_projects | imz_corridor_projects_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_projects | imz_corridor_projects_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_clusters | imz_corridor_clusters_corridor_id_idx | corridor_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_clusters | imz_corridor_clusters_cluster_id_idx | cluster_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_clusters | imz_corridor_clusters_corridor_id_cluster_id_uq | corridor_id,cluster_id | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_clusters | imz_corridor_clusters_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| corridor_clusters | imz_corridor_clusters_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| investors | imz_investors_organization_id_idx | organization_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| investors | imz_investors_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| investors | imz_investors_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| investors | imz_investors_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| contacts | imz_contacts_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| contacts | imz_contacts_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| investor_contacts | imz_investor_contacts_investor_id_idx | investor_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| investor_contacts | imz_investor_contacts_contact_id_idx | contact_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| investor_contacts | imz_investor_contacts_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| leads | imz_leads_investor_id_idx | investor_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| leads | imz_leads_owner_user_id_idx | owner_user_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| leads | imz_leads_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| leads | imz_leads_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| leads | imz_leads_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| crm_opportunities | imz_crm_opportunities_lead_id_idx | lead_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| crm_opportunities | imz_crm_opportunities_project_id_idx | project_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| crm_opportunities | imz_crm_opportunities_owner_user_id_idx | owner_user_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| crm_opportunities | imz_crm_opportunities_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| crm_opportunities | imz_crm_opportunities_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| activities | imz_activities_investor_id_idx | investor_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| activities | imz_activities_opportunity_id_idx | opportunity_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| activities | imz_activities_actor_user_id_idx | actor_user_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| activities | imz_activities_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| activities | imz_activities_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| tasks | imz_tasks_assignee_user_id_idx | assignee_user_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| tasks | imz_tasks_opportunity_id_idx | opportunity_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| tasks | imz_tasks_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| tasks | imz_tasks_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| tasks | imz_tasks_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| investor_journeys | imz_investor_journeys_investor_id_idx | investor_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| investor_journeys | imz_investor_journeys_opportunity_id_idx | opportunity_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| investor_journeys | imz_investor_journeys_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| investor_journeys | imz_investor_journeys_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| investor_journeys | imz_investor_journeys_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| journey_steps | imz_journey_steps_journey_id_idx | journey_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| journey_steps | imz_journey_steps_journey_id_sequence_no_uq | journey_id,sequence_no | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| journey_steps | imz_journey_steps_journey_id_step_code_uq | journey_id,step_code | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| journey_steps | imz_journey_steps_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| journey_steps | imz_journey_steps_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| journey_steps | imz_journey_steps_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| submissions | imz_submissions_journey_step_id_idx | journey_step_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| submissions | imz_submissions_submitted_by_idx | submitted_by | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| submissions | imz_submissions_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| submissions | imz_submissions_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| submissions | imz_submissions_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| aftercare_plans | imz_aftercare_plans_investor_id_idx | investor_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| aftercare_plans | imz_aftercare_plans_project_id_idx | project_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| aftercare_plans | imz_aftercare_plans_owner_user_id_idx | owner_user_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| aftercare_plans | imz_aftercare_plans_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| aftercare_plans | imz_aftercare_plans_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| aftercare_plans | imz_aftercare_plans_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| constraints | imz_constraints_aftercare_plan_id_idx | aftercare_plan_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| constraints | imz_constraints_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| constraints | imz_constraints_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| constraints | imz_constraints_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| escalations | imz_escalations_constraint_id_idx | constraint_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| escalations | imz_escalations_escalated_by_idx | escalated_by | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| escalations | imz_escalations_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| escalations | imz_escalations_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| escalations | imz_escalations_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| skills | imz_skills_code_uq | code | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| skills | imz_skills_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| skills | imz_skills_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| occupations | imz_occupations_code_uq | code | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| occupations | imz_occupations_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| occupations | imz_occupations_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| training_needs | imz_training_needs_project_id_idx | project_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| training_needs | imz_training_needs_occupation_id_idx | occupation_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| training_needs | imz_training_needs_skill_id_idx | skill_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| training_needs | imz_training_needs_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| training_needs | imz_training_needs_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| training_providers | imz_training_providers_organization_id_idx | organization_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| training_providers | imz_training_providers_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| training_providers | imz_training_providers_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| training_providers | imz_training_providers_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| courses | imz_courses_provider_id_idx | provider_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| courses | imz_courses_provider_id_code_uq | provider_id,code | btree | True | deleted_at IS NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| courses | imz_courses_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| courses | imz_courses_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| courses | imz_courses_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| education_opportunities | imz_education_opportunities_course_id_idx | course_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| education_opportunities | imz_education_opportunities_geo_location_id_idx | geo_location_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| education_opportunities | imz_education_opportunities_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| education_opportunities | imz_education_opportunities_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| education_opportunities | imz_education_opportunities_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| project_documents | imz_project_documents_project_id_idx | project_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| project_documents | imz_project_documents_document_id_idx | document_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| investor_documents | imz_investor_documents_investor_id_idx | investor_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| investor_documents | imz_investor_documents_document_id_idx | document_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| journey_documents | imz_journey_documents_journey_id_idx | journey_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| journey_documents | imz_journey_documents_document_id_idx | document_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| aftercare_documents | imz_aftercare_documents_aftercare_plan_id_idx | aftercare_plan_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| aftercare_documents | imz_aftercare_documents_document_id_idx | document_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| education_documents | imz_education_documents_education_opportunity_id_idx | education_opportunity_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| education_documents | imz_education_documents_document_id_idx | document_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| audit_events | imz_audit_events_actor_user_id_idx | actor_user_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| audit_events | imz_audit_events_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| outbox_events | imz_outbox_events_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| outbox_events | imz_outbox_events_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| outbox_events | imz_outbox_events_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| inbox_events | imz_inbox_events_message_id_uq | message_id | btree | True | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| inbox_events | imz_inbox_events_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| inbox_events | imz_inbox_events_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| inbox_events | imz_inbox_events_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| sync_runs | imz_sync_runs_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| sync_runs | imz_sync_runs_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| sync_runs | imz_sync_runs_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| reconciliation_issues | imz_reconciliation_issues_sync_run_id_idx | sync_run_id | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| reconciliation_issues | imz_reconciliation_issues_source_system_external_id_uq | source_system,external_id | btree | True | external_id IS NOT NULL | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| reconciliation_issues | imz_reconciliation_issues_updated_at_idx | updated_at | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| reconciliation_issues | imz_reconciliation_issues_status_idx | status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| geo_geometries | imz_geo_geometries_properties_idx | properties | gin | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| projects | imz_projects_metadata_idx | metadata | gin | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| submissions | imz_submissions_payload_idx | payload | gin | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| audit_events | imz_audit_events_after_data_idx | after_data | gin | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| outbox_events | imz_outbox_events_status_available_at_idx | status,available_at | btree | False | status = 'pending' | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| reconciliation_issues | imz_reconciliation_issues_sync_run_id_status_idx | sync_run_id,status | btree | False | | database/migrations/2026_09_24_012000_investmoz_indexes.php | +| users | users_pkey | id | btree | True | | database/migrations/0001_01_01_000000_create_users_table.php | +| password_reset_tokens | password_reset_tokens_pkey | email | btree | True | | database/migrations/0001_01_01_000000_create_users_table.php | +| sessions | sessions_pkey | id | btree | True | | database/migrations/0001_01_01_000000_create_users_table.php | +| cache | cache_pkey | key | btree | True | | database/migrations/0001_01_01_000001_create_cache_table.php | +| cache_locks | cache_locks_pkey | key | btree | True | | database/migrations/0001_01_01_000001_create_cache_table.php | +| jobs | jobs_pkey | id | btree | True | | database/migrations/0001_01_01_000002_create_jobs_table.php | +| job_batches | job_batches_pkey | id | btree | True | | database/migrations/0001_01_01_000002_create_jobs_table.php | +| failed_jobs | failed_jobs_pkey | id | btree | True | | database/migrations/0001_01_01_000002_create_jobs_table.php | +| passkeys | passkeys_pkey | id | btree | True | | database/migrations/2024_01_01_000000_create_passkeys_table.php | +| sectors | sectors_pkey | id | btree | True | | database/migrations/2026_01_01_000001_create_sectors_table.php | +| opportunities | opportunities_pkey | id | btree | True | | database/migrations/2026_01_01_000002_create_opportunities_table.php | +| interest_requests | interest_requests_pkey | id | btree | True | | database/migrations/2026_01_01_000003_create_interest_requests_table.php | +| organizations | imz_organizations_pk | id | btree | True | | database/migrations/2026_09_24_001000_investmoz_identity.php | +| memberships | imz_memberships_pk | id | btree | True | | database/migrations/2026_09_24_001000_investmoz_identity.php | +| consents | imz_consents_pk | id | btree | True | | database/migrations/2026_09_24_001000_investmoz_identity.php | +| documents | imz_documents_pk | id | btree | True | | database/migrations/2026_09_24_002000_investmoz_content.php | +| geo_locations | imz_geo_locations_pk | id | btree | True | | database/migrations/2026_09_24_003000_investmoz_geography.php | +| geo_geometries | imz_geo_geometries_pk | id | btree | True | | database/migrations/2026_09_24_003000_investmoz_geography.php | +| projects | imz_projects_pk | id | btree | True | | database/migrations/2026_09_24_004000_investmoz_projects.php | +| project_locations | imz_project_locations_pk | id | btree | True | | database/migrations/2026_09_24_004000_investmoz_projects.php | +| economic_clusters | imz_economic_clusters_pk | id | btree | True | | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| value_chains | imz_value_chains_pk | id | btree | True | | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| value_chain_links | imz_value_chain_links_pk | id | btree | True | | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| cluster_projects | imz_cluster_projects_pk | id | btree | True | | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| anchor_project_links | imz_anchor_project_links_pk | id | btree | True | | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| complementary_project_links | imz_complementary_project_links_pk | id | btree | True | | database/migrations/2026_09_24_005000_investmoz_clusters.php | +| development_corridors | imz_development_corridors_pk | id | btree | True | | database/migrations/2026_09_24_006000_investmoz_corridors.php | +| corridor_locations | imz_corridor_locations_pk | id | btree | True | | database/migrations/2026_09_24_006000_investmoz_corridors.php | +| corridor_projects | imz_corridor_projects_pk | id | btree | True | | database/migrations/2026_09_24_006000_investmoz_corridors.php | +| corridor_clusters | imz_corridor_clusters_pk | id | btree | True | | database/migrations/2026_09_24_006000_investmoz_corridors.php | +| investors | imz_investors_pk | id | btree | True | | database/migrations/2026_09_24_007000_investmoz_crm.php | +| contacts | imz_contacts_pk | id | btree | True | | database/migrations/2026_09_24_007000_investmoz_crm.php | +| investor_contacts | imz_investor_contacts_pk | investor_id,contact_id | btree | True | | database/migrations/2026_09_24_007000_investmoz_crm.php | +| leads | imz_leads_pk | id | btree | True | | database/migrations/2026_09_24_007000_investmoz_crm.php | +| crm_opportunities | imz_crm_opportunities_pk | id | btree | True | | database/migrations/2026_09_24_007000_investmoz_crm.php | +| activities | imz_activities_pk | id | btree | True | | database/migrations/2026_09_24_007000_investmoz_crm.php | +| tasks | imz_tasks_pk | id | btree | True | | database/migrations/2026_09_24_007000_investmoz_crm.php | +| investor_journeys | imz_investor_journeys_pk | id | btree | True | | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| journey_steps | imz_journey_steps_pk | id | btree | True | | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| submissions | imz_submissions_pk | id | btree | True | | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| aftercare_plans | imz_aftercare_plans_pk | id | btree | True | | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| constraints | imz_constraints_pk | id | btree | True | | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| escalations | imz_escalations_pk | id | btree | True | | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | +| skills | imz_skills_pk | id | btree | True | | database/migrations/2026_09_24_009000_investmoz_education.php | +| occupations | imz_occupations_pk | id | btree | True | | database/migrations/2026_09_24_009000_investmoz_education.php | +| training_needs | imz_training_needs_pk | id | btree | True | | database/migrations/2026_09_24_009000_investmoz_education.php | +| training_providers | imz_training_providers_pk | id | btree | True | | database/migrations/2026_09_24_009000_investmoz_education.php | +| courses | imz_courses_pk | id | btree | True | | database/migrations/2026_09_24_009000_investmoz_education.php | +| education_opportunities | imz_education_opportunities_pk | id | btree | True | | database/migrations/2026_09_24_009000_investmoz_education.php | +| project_documents | imz_project_documents_pk | project_id,document_id | btree | True | | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| investor_documents | imz_investor_documents_pk | investor_id,document_id | btree | True | | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| journey_documents | imz_journey_documents_pk | journey_id,document_id | btree | True | | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| aftercare_documents | imz_aftercare_documents_pk | aftercare_plan_id,document_id | btree | True | | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| education_documents | imz_education_documents_pk | education_opportunity_id,document_id | btree | True | | database/migrations/2026_09_24_009500_investmoz_document_links.php | +| audit_events | imz_audit_events_pk | id | btree | True | | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | +| outbox_events | imz_outbox_events_pk | id | btree | True | | database/migrations/2026_09_24_011000_investmoz_integrations.php | +| inbox_events | imz_inbox_events_pk | id | btree | True | | database/migrations/2026_09_24_011000_investmoz_integrations.php | +| sync_runs | imz_sync_runs_pk | id | btree | True | | database/migrations/2026_09_24_011000_investmoz_integrations.php | +| reconciliation_issues | imz_reconciliation_issues_pk | id | btree | True | | database/migrations/2026_09_24_011000_investmoz_integrations.php | + +```json +{ + "schemas": [ + "public" + ], + "framework_owned_objects": [ + "migrations (Laravel)" + ], + "extension_owned_objects": "Objectos PostGIS pertencem à extensão; não são duplicados no dicionário aplicacional.", + "extensions": [ + "postgis", + "pgcrypto" + ], + "functions": [ + "CREATE OR REPLACE FUNCTION public.investmoz_set_updated_at() RETURNS trigger\nLANGUAGE plpgsql SET search_path = pg_catalog, public AS $$\nBEGIN\n NEW.updated_at = CURRENT_TIMESTAMP;\n RETURN NEW;\nEND;\n$$;", + "CREATE OR REPLACE FUNCTION public.investmoz_reject_audit_change() RETURNS trigger\nLANGUAGE plpgsql SET search_path = pg_catalog, public AS $$\nBEGIN\n RAISE EXCEPTION 'InvestMoz audit_events is append-only' USING ERRCODE = '42501';\nEND;\n$$;", + "CREATE OR REPLACE FUNCTION public.investmoz_protect_event_envelope() RETURNS trigger\nLANGUAGE plpgsql SET search_path = pg_catalog, public AS $$\nBEGIN\n IF TG_OP = 'DELETE' THEN\n RAISE EXCEPTION 'InvestMoz event deletion requires a reviewed retention migration' USING ERRCODE = '42501';\n END IF;\n IF (to_jsonb(NEW) - ARRAY['status','updated_at','published_at','processed_at','available_at'])\n IS DISTINCT FROM\n (to_jsonb(OLD) - ARRAY['status','updated_at','published_at','processed_at','available_at']) THEN\n RAISE EXCEPTION 'InvestMoz event envelope is immutable' USING ERRCODE = '42501';\n END IF;\n RETURN NEW;\nEND;\n$$;" + ], + "triggers": [ + { + "name": "imz_organizations_updated", + "table": "organizations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_memberships_updated", + "table": "memberships", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_consents_updated", + "table": "consents", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_documents_updated", + "table": "documents", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_geo_locations_updated", + "table": "geo_locations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_geo_geometries_updated", + "table": "geo_geometries", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_projects_updated", + "table": "projects", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_project_locations_updated", + "table": "project_locations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_economic_clusters_updated", + "table": "economic_clusters", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_value_chains_updated", + "table": "value_chains", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_value_chain_links_updated", + "table": "value_chain_links", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_cluster_projects_updated", + "table": "cluster_projects", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_anchor_project_links_updated", + "table": "anchor_project_links", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_complementary_project_links_updated", + "table": "complementary_project_links", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_development_corridors_updated", + "table": "development_corridors", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_corridor_locations_updated", + "table": "corridor_locations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_corridor_projects_updated", + "table": "corridor_projects", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_corridor_clusters_updated", + "table": "corridor_clusters", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_investors_updated", + "table": "investors", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_contacts_updated", + "table": "contacts", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_investor_contacts_updated", + "table": "investor_contacts", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_leads_updated", + "table": "leads", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_crm_opportunities_updated", + "table": "crm_opportunities", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_activities_updated", + "table": "activities", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_tasks_updated", + "table": "tasks", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_investor_journeys_updated", + "table": "investor_journeys", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_journey_steps_updated", + "table": "journey_steps", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_submissions_updated", + "table": "submissions", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_aftercare_plans_updated", + "table": "aftercare_plans", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_constraints_updated", + "table": "constraints", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_escalations_updated", + "table": "escalations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_skills_updated", + "table": "skills", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_occupations_updated", + "table": "occupations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_training_needs_updated", + "table": "training_needs", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_training_providers_updated", + "table": "training_providers", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_courses_updated", + "table": "courses", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_education_opportunities_updated", + "table": "education_opportunities", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_outbox_events_updated", + "table": "outbox_events", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_inbox_events_updated", + "table": "inbox_events", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_sync_runs_updated", + "table": "sync_runs", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_reconciliation_issues_updated", + "table": "reconciliation_issues", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_audit_events_immutable", + "table": "audit_events", + "event": "BEFORE UPDATE OR DELETE OR TRUNCATE", + "function": "investmoz_reject_audit_change", + "level": "STATEMENT" + }, + { + "name": "imz_outbox_events_envelope", + "table": "outbox_events", + "event": "BEFORE UPDATE OR DELETE", + "function": "investmoz_protect_event_envelope", + "level": "ROW" + }, + { + "name": "imz_outbox_events_no_truncate", + "table": "outbox_events", + "event": "BEFORE TRUNCATE", + "function": "investmoz_reject_audit_change", + "level": "STATEMENT" + }, + { + "name": "imz_inbox_events_envelope", + "table": "inbox_events", + "event": "BEFORE UPDATE OR DELETE", + "function": "investmoz_protect_event_envelope", + "level": "ROW" + }, + { + "name": "imz_inbox_events_no_truncate", + "table": "inbox_events", + "event": "BEFORE TRUNCATE", + "function": "investmoz_reject_audit_change", + "level": "STATEMENT" + } + ], + "views": [], + "materialized_views": [], + "custom_enums": [], + "generated_columns": [], + "sequences": [ + "users_id_seq", + "jobs_id_seq", + "failed_jobs_id_seq", + "passkeys_id_seq", + "sectors_id_seq", + "opportunities_id_seq", + "interest_requests_id_seq" + ], + "security_policies": [], + "audit": "append-only; writers must not own the tables or possess DDL privileges", + "logical_links_without_fk": [ + "sessions.user_id -> users.id", + "password_reset_tokens.email -> users.email", + "audit_events.entity_type/entity_id", + "outbox_events.aggregate_type/aggregate_id" + ] +} +``` + +## Migrations completas + +### database/migrations/2026_09_24_000100_investmoz_extensions_functions.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE EXTENSION IF NOT EXISTS postgis; +SQL); + + DB::statement(<<<'SQL' +CREATE EXTENSION IF NOT EXISTS pgcrypto; +SQL); + + DB::statement(<<<'SQL' +CREATE OR REPLACE FUNCTION public.investmoz_set_updated_at() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$; +SQL); + + DB::statement(<<<'SQL' +CREATE OR REPLACE FUNCTION public.investmoz_reject_audit_change() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$ +BEGIN + RAISE EXCEPTION 'InvestMoz audit_events is append-only' USING ERRCODE = '42501'; +END; +$$; +SQL); + + DB::statement(<<<'SQL' +CREATE OR REPLACE FUNCTION public.investmoz_protect_event_envelope() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'InvestMoz event deletion requires a reviewed retention migration' USING ERRCODE = '42501'; + END IF; + IF (to_jsonb(NEW) - ARRAY['status','updated_at','published_at','processed_at','available_at']) + IS DISTINCT FROM + (to_jsonb(OLD) - ARRAY['status','updated_at','published_at','processed_at','available_at']) THEN + RAISE EXCEPTION 'InvestMoz event envelope is immutable' USING ERRCODE = '42501'; + END IF; + RETURN NEW; +END; +$$; +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +DROP FUNCTION IF EXISTS public.investmoz_protect_event_envelope() RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP FUNCTION IF EXISTS public.investmoz_reject_audit_change() RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP FUNCTION IF EXISTS public.investmoz_set_updated_at() RESTRICT; +SQL); + } +}; +``` + +### database/migrations/2026_09_24_001000_investmoz_identity.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.organizations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + name text NOT NULL, + organization_type text NOT NULL, + CONSTRAINT imz_organizations_pk PRIMARY KEY (id), + CONSTRAINT imz_organizations_check_01 CHECK (version_no > 0), + CONSTRAINT imz_organizations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_organizations_check_03 CHECK (organization_type IN ('public','private','ngo','education')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.memberships ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + user_id bigint NOT NULL CONSTRAINT imz_memberships_user_id_fk REFERENCES public.users(id) ON DELETE CASCADE ON UPDATE CASCADE, + organization_id uuid NOT NULL CONSTRAINT imz_memberships_organization_id_fk REFERENCES public.organizations(id) ON DELETE CASCADE ON UPDATE CASCADE, + role text NOT NULL, + CONSTRAINT imz_memberships_pk PRIMARY KEY (id), + CONSTRAINT imz_memberships_check_01 CHECK (version_no > 0), + CONSTRAINT imz_memberships_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_memberships_check_03 CHECK (role IN ('admin','manager','officer','viewer')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.consents ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + user_id bigint CONSTRAINT imz_consents_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + subject_ref text NOT NULL, + purpose text NOT NULL, + status text NOT NULL, + granted_at timestamptz, + withdrawn_at timestamptz, + CONSTRAINT imz_consents_pk PRIMARY KEY (id), + CONSTRAINT imz_consents_check_01 CHECK (version_no > 0), + CONSTRAINT imz_consents_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_consents_check_03 CHECK (status IN ('granted','withdrawn','expired')) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.consents DROP CONSTRAINT IF EXISTS imz_consents_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.consents DROP CONSTRAINT IF EXISTS imz_consents_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.consents DROP CONSTRAINT IF EXISTS imz_consents_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.consents DROP CONSTRAINT IF EXISTS imz_consents_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.consents DROP CONSTRAINT IF EXISTS imz_consents_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.consents RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.memberships DROP CONSTRAINT IF EXISTS imz_memberships_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.memberships DROP CONSTRAINT IF EXISTS imz_memberships_organization_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.memberships DROP CONSTRAINT IF EXISTS imz_memberships_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.memberships DROP CONSTRAINT IF EXISTS imz_memberships_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.memberships DROP CONSTRAINT IF EXISTS imz_memberships_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.memberships DROP CONSTRAINT IF EXISTS imz_memberships_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.memberships RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.organizations DROP CONSTRAINT IF EXISTS imz_organizations_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.organizations DROP CONSTRAINT IF EXISTS imz_organizations_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.organizations DROP CONSTRAINT IF EXISTS imz_organizations_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.organizations DROP CONSTRAINT IF EXISTS imz_organizations_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.organizations RESTRICT; +SQL); + } +}; +``` + +### database/migrations/2026_09_24_002000_investmoz_content.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.documents ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + file_name text NOT NULL, + media_type text NOT NULL, + storage_key text NOT NULL, + sha256 char(64) NOT NULL, + status text NOT NULL, + CONSTRAINT imz_documents_pk PRIMARY KEY (id), + CONSTRAINT imz_documents_check_01 CHECK (version_no > 0), + CONSTRAINT imz_documents_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_documents_check_03 CHECK (status IN ('active','quarantined','deleted')), + CONSTRAINT imz_documents_check_04 CHECK (sha256 ~ '^[0-9a-f]{64}$') +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.documents DROP CONSTRAINT IF EXISTS imz_documents_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.documents DROP CONSTRAINT IF EXISTS imz_documents_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.documents DROP CONSTRAINT IF EXISTS imz_documents_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.documents DROP CONSTRAINT IF EXISTS imz_documents_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.documents DROP CONSTRAINT IF EXISTS imz_documents_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.documents RESTRICT; +SQL); + } +}; +``` + +### database/migrations/2026_09_24_003000_investmoz_geography.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.geo_locations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + name text NOT NULL, + location_type text NOT NULL, + parent_id uuid CONSTRAINT imz_geo_locations_parent_id_fk REFERENCES public.geo_locations(id) ON DELETE RESTRICT ON UPDATE CASCADE, + code text NOT NULL, + CONSTRAINT imz_geo_locations_pk PRIMARY KEY (id), + CONSTRAINT imz_geo_locations_check_01 CHECK (version_no > 0), + CONSTRAINT imz_geo_locations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_geo_locations_check_03 CHECK (location_type IN ('country','province','district','municipality','site')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.geo_geometries ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + geo_location_id uuid NOT NULL CONSTRAINT imz_geo_geometries_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE CASCADE ON UPDATE CASCADE, + geometry_type text NOT NULL, + geom geometry(Geometry,4326) NOT NULL, + properties jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT imz_geo_geometries_pk PRIMARY KEY (id), + CONSTRAINT imz_geo_geometries_check_01 CHECK (version_no > 0), + CONSTRAINT imz_geo_geometries_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_geo_geometries_check_03 CHECK (geometry_type IN ('point','line','polygon','multipolygon')), + CONSTRAINT imz_geo_geometries_check_04 CHECK (ST_SRID(geom) = 4326), + CONSTRAINT imz_geo_geometries_check_05 CHECK (ST_NDims(geom) = 2), + CONSTRAINT imz_geo_geometries_check_06 CHECK (NOT ST_IsEmpty(geom)), + CONSTRAINT imz_geo_geometries_check_07 CHECK (ST_IsValid(geom)), + CONSTRAINT imz_geo_geometries_check_08 CHECK ((geometry_type = 'point' AND GeometryType(geom) = 'POINT') OR (geometry_type = 'line' AND GeometryType(geom) = 'LINESTRING') OR (geometry_type = 'polygon' AND GeometryType(geom) = 'POLYGON') OR (geometry_type = 'multipolygon' AND GeometryType(geom) = 'MULTIPOLYGON')), + CONSTRAINT imz_geo_geometries_check_09 CHECK (ST_XMin(Box3D(geom)) >= -180 AND ST_XMax(Box3D(geom)) <= 180 AND ST_YMin(Box3D(geom)) >= -90 AND ST_YMax(Box3D(geom)) <= 90) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_geo_location_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_05 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_06 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_07 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_08 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_geometries DROP CONSTRAINT IF EXISTS imz_geo_geometries_check_09 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.geo_geometries RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_locations DROP CONSTRAINT IF EXISTS imz_geo_locations_parent_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_locations DROP CONSTRAINT IF EXISTS imz_geo_locations_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_locations DROP CONSTRAINT IF EXISTS imz_geo_locations_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_locations DROP CONSTRAINT IF EXISTS imz_geo_locations_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.geo_locations DROP CONSTRAINT IF EXISTS imz_geo_locations_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.geo_locations RESTRICT; +SQL); + } +}; +``` + +### database/migrations/2026_09_24_004000_investmoz_projects.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.projects ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + code text NOT NULL, + name text NOT NULL, + status text NOT NULL, + description text, + budget_amount numeric(20,2), + budget_currency char(3), + start_date date, + end_date date, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT imz_projects_pk PRIMARY KEY (id), + CONSTRAINT imz_projects_check_01 CHECK (version_no > 0), + CONSTRAINT imz_projects_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_projects_check_03 CHECK (status IN ('draft','planned','active','suspended','completed','cancelled')), + CONSTRAINT imz_projects_check_04 CHECK (budget_amount IS NULL OR budget_amount >= 0), + CONSTRAINT imz_projects_check_05 CHECK (end_date IS NULL OR start_date IS NULL OR end_date >= start_date), + CONSTRAINT imz_projects_check_06 CHECK ((budget_amount IS NULL) = (budget_currency IS NULL)), + CONSTRAINT imz_projects_check_07 CHECK (budget_currency IS NULL OR budget_currency ~ '^[A-Z]{3}$') +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.project_locations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + project_id uuid NOT NULL CONSTRAINT imz_project_locations_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE, + geo_location_id uuid NOT NULL CONSTRAINT imz_project_locations_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE RESTRICT ON UPDATE CASCADE, + role text NOT NULL, + CONSTRAINT imz_project_locations_pk PRIMARY KEY (id), + CONSTRAINT imz_project_locations_check_01 CHECK (version_no > 0), + CONSTRAINT imz_project_locations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_project_locations_check_03 CHECK (role IN ('primary','secondary','impact')) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_locations DROP CONSTRAINT IF EXISTS imz_project_locations_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_locations DROP CONSTRAINT IF EXISTS imz_project_locations_geo_location_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_locations DROP CONSTRAINT IF EXISTS imz_project_locations_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_locations DROP CONSTRAINT IF EXISTS imz_project_locations_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_locations DROP CONSTRAINT IF EXISTS imz_project_locations_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_locations DROP CONSTRAINT IF EXISTS imz_project_locations_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.project_locations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_check_05 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_check_06 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.projects DROP CONSTRAINT IF EXISTS imz_projects_check_07 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.projects RESTRICT; +SQL); + } +}; +``` + +### database/migrations/2026_09_24_005000_investmoz_clusters.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.economic_clusters ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + code text NOT NULL, + name text NOT NULL, + sector text NOT NULL, + status text NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT imz_economic_clusters_pk PRIMARY KEY (id), + CONSTRAINT imz_economic_clusters_check_01 CHECK (version_no > 0), + CONSTRAINT imz_economic_clusters_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_economic_clusters_check_03 CHECK (status IN ('planned','active','inactive')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.value_chains ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + cluster_id uuid NOT NULL CONSTRAINT imz_value_chains_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE, + code text NOT NULL, + name text NOT NULL, + stage text NOT NULL, + CONSTRAINT imz_value_chains_pk PRIMARY KEY (id), + CONSTRAINT imz_value_chains_check_01 CHECK (version_no > 0), + CONSTRAINT imz_value_chains_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_value_chains_check_03 CHECK (stage IN ('input','production','processing','logistics','market')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.value_chain_links ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + from_value_chain_id uuid NOT NULL CONSTRAINT imz_value_chain_links_from_value_chain_id_fk REFERENCES public.value_chains(id) ON DELETE CASCADE ON UPDATE CASCADE, + to_value_chain_id uuid NOT NULL CONSTRAINT imz_value_chain_links_to_value_chain_id_fk REFERENCES public.value_chains(id) ON DELETE CASCADE ON UPDATE CASCADE, + link_type text NOT NULL, + CONSTRAINT imz_value_chain_links_pk PRIMARY KEY (id), + CONSTRAINT imz_value_chain_links_check_01 CHECK (version_no > 0), + CONSTRAINT imz_value_chain_links_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_value_chain_links_check_03 CHECK (link_type IN ('supplies','processes','distributes','supports')), + CONSTRAINT imz_value_chain_links_check_04 CHECK (from_value_chain_id <> to_value_chain_id) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.cluster_projects ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + cluster_id uuid NOT NULL CONSTRAINT imz_cluster_projects_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE, + project_id uuid NOT NULL CONSTRAINT imz_cluster_projects_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE, + role text NOT NULL, + CONSTRAINT imz_cluster_projects_pk PRIMARY KEY (id), + CONSTRAINT imz_cluster_projects_check_01 CHECK (version_no > 0), + CONSTRAINT imz_cluster_projects_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_cluster_projects_check_03 CHECK (role IN ('anchor','complementary','supporting')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.anchor_project_links ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + anchor_project_id uuid NOT NULL CONSTRAINT imz_anchor_project_links_anchor_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE, + cluster_id uuid NOT NULL CONSTRAINT imz_anchor_project_links_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE, + rationale text NOT NULL, + CONSTRAINT imz_anchor_project_links_pk PRIMARY KEY (id), + CONSTRAINT imz_anchor_project_links_check_01 CHECK (version_no > 0), + CONSTRAINT imz_anchor_project_links_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.complementary_project_links ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + anchor_project_id uuid NOT NULL CONSTRAINT imz_complementary_project_links_anchor_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE, + complementary_project_id uuid NOT NULL CONSTRAINT imz_complementary_project_links_complementary_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE, + relation_type text NOT NULL, + CONSTRAINT imz_complementary_project_links_pk PRIMARY KEY (id), + CONSTRAINT imz_complementary_project_links_check_01 CHECK (version_no > 0), + CONSTRAINT imz_complementary_project_links_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_complementary_project_links_check_03 CHECK (relation_type IN ('dependency','enabler','supplier','market')), + CONSTRAINT imz_complementary_project_links_check_04 CHECK (anchor_project_id<>complementary_project_id) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.complementary_project_links DROP CONSTRAINT IF EXISTS imz_complementary_project_links_anchor_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.complementary_project_links DROP CONSTRAINT IF EXISTS imz_complementary_project_links_complementary_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.complementary_project_links DROP CONSTRAINT IF EXISTS imz_complementary_project_links_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.complementary_project_links DROP CONSTRAINT IF EXISTS imz_complementary_project_links_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.complementary_project_links DROP CONSTRAINT IF EXISTS imz_complementary_project_links_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.complementary_project_links DROP CONSTRAINT IF EXISTS imz_complementary_project_links_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.complementary_project_links DROP CONSTRAINT IF EXISTS imz_complementary_project_links_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.complementary_project_links RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.anchor_project_links DROP CONSTRAINT IF EXISTS imz_anchor_project_links_anchor_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.anchor_project_links DROP CONSTRAINT IF EXISTS imz_anchor_project_links_cluster_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.anchor_project_links DROP CONSTRAINT IF EXISTS imz_anchor_project_links_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.anchor_project_links DROP CONSTRAINT IF EXISTS imz_anchor_project_links_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.anchor_project_links DROP CONSTRAINT IF EXISTS imz_anchor_project_links_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.anchor_project_links RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.cluster_projects DROP CONSTRAINT IF EXISTS imz_cluster_projects_cluster_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.cluster_projects DROP CONSTRAINT IF EXISTS imz_cluster_projects_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.cluster_projects DROP CONSTRAINT IF EXISTS imz_cluster_projects_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.cluster_projects DROP CONSTRAINT IF EXISTS imz_cluster_projects_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.cluster_projects DROP CONSTRAINT IF EXISTS imz_cluster_projects_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.cluster_projects DROP CONSTRAINT IF EXISTS imz_cluster_projects_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.cluster_projects RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chain_links DROP CONSTRAINT IF EXISTS imz_value_chain_links_from_value_chain_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chain_links DROP CONSTRAINT IF EXISTS imz_value_chain_links_to_value_chain_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chain_links DROP CONSTRAINT IF EXISTS imz_value_chain_links_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chain_links DROP CONSTRAINT IF EXISTS imz_value_chain_links_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chain_links DROP CONSTRAINT IF EXISTS imz_value_chain_links_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chain_links DROP CONSTRAINT IF EXISTS imz_value_chain_links_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chain_links DROP CONSTRAINT IF EXISTS imz_value_chain_links_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.value_chain_links RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chains DROP CONSTRAINT IF EXISTS imz_value_chains_cluster_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chains DROP CONSTRAINT IF EXISTS imz_value_chains_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chains DROP CONSTRAINT IF EXISTS imz_value_chains_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chains DROP CONSTRAINT IF EXISTS imz_value_chains_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.value_chains DROP CONSTRAINT IF EXISTS imz_value_chains_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.value_chains RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.economic_clusters DROP CONSTRAINT IF EXISTS imz_economic_clusters_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.economic_clusters DROP CONSTRAINT IF EXISTS imz_economic_clusters_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.economic_clusters DROP CONSTRAINT IF EXISTS imz_economic_clusters_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.economic_clusters DROP CONSTRAINT IF EXISTS imz_economic_clusters_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.economic_clusters RESTRICT; +SQL); + } +}; +``` + +### database/migrations/2026_09_24_006000_investmoz_corridors.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.development_corridors ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + code text NOT NULL, + name text NOT NULL, + status text NOT NULL, + description text, + CONSTRAINT imz_development_corridors_pk PRIMARY KEY (id), + CONSTRAINT imz_development_corridors_check_01 CHECK (version_no > 0), + CONSTRAINT imz_development_corridors_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_development_corridors_check_03 CHECK (status IN ('proposed','active','closed')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.corridor_locations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + corridor_id uuid NOT NULL CONSTRAINT imz_corridor_locations_corridor_id_fk REFERENCES public.development_corridors(id) ON DELETE CASCADE ON UPDATE CASCADE, + geo_location_id uuid NOT NULL CONSTRAINT imz_corridor_locations_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE RESTRICT ON UPDATE CASCADE, + sequence_no integer NOT NULL, + CONSTRAINT imz_corridor_locations_pk PRIMARY KEY (id), + CONSTRAINT imz_corridor_locations_check_01 CHECK (version_no > 0), + CONSTRAINT imz_corridor_locations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_corridor_locations_check_03 CHECK (sequence_no>0) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.corridor_projects ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + corridor_id uuid NOT NULL CONSTRAINT imz_corridor_projects_corridor_id_fk REFERENCES public.development_corridors(id) ON DELETE CASCADE ON UPDATE CASCADE, + project_id uuid NOT NULL CONSTRAINT imz_corridor_projects_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT imz_corridor_projects_pk PRIMARY KEY (id), + CONSTRAINT imz_corridor_projects_check_01 CHECK (version_no > 0), + CONSTRAINT imz_corridor_projects_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.corridor_clusters ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + corridor_id uuid NOT NULL CONSTRAINT imz_corridor_clusters_corridor_id_fk REFERENCES public.development_corridors(id) ON DELETE CASCADE ON UPDATE CASCADE, + cluster_id uuid NOT NULL CONSTRAINT imz_corridor_clusters_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT imz_corridor_clusters_pk PRIMARY KEY (id), + CONSTRAINT imz_corridor_clusters_check_01 CHECK (version_no > 0), + CONSTRAINT imz_corridor_clusters_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_clusters DROP CONSTRAINT IF EXISTS imz_corridor_clusters_corridor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_clusters DROP CONSTRAINT IF EXISTS imz_corridor_clusters_cluster_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_clusters DROP CONSTRAINT IF EXISTS imz_corridor_clusters_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_clusters DROP CONSTRAINT IF EXISTS imz_corridor_clusters_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_clusters DROP CONSTRAINT IF EXISTS imz_corridor_clusters_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.corridor_clusters RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_projects DROP CONSTRAINT IF EXISTS imz_corridor_projects_corridor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_projects DROP CONSTRAINT IF EXISTS imz_corridor_projects_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_projects DROP CONSTRAINT IF EXISTS imz_corridor_projects_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_projects DROP CONSTRAINT IF EXISTS imz_corridor_projects_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_projects DROP CONSTRAINT IF EXISTS imz_corridor_projects_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.corridor_projects RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_locations DROP CONSTRAINT IF EXISTS imz_corridor_locations_corridor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_locations DROP CONSTRAINT IF EXISTS imz_corridor_locations_geo_location_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_locations DROP CONSTRAINT IF EXISTS imz_corridor_locations_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_locations DROP CONSTRAINT IF EXISTS imz_corridor_locations_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_locations DROP CONSTRAINT IF EXISTS imz_corridor_locations_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.corridor_locations DROP CONSTRAINT IF EXISTS imz_corridor_locations_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.corridor_locations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.development_corridors DROP CONSTRAINT IF EXISTS imz_development_corridors_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.development_corridors DROP CONSTRAINT IF EXISTS imz_development_corridors_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.development_corridors DROP CONSTRAINT IF EXISTS imz_development_corridors_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.development_corridors DROP CONSTRAINT IF EXISTS imz_development_corridors_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.development_corridors RESTRICT; +SQL); + } +}; +``` + +### database/migrations/2026_09_24_007000_investmoz_crm.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.investors ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + organization_id uuid CONSTRAINT imz_investors_organization_id_fk REFERENCES public.organizations(id) ON DELETE SET NULL ON UPDATE CASCADE, + legal_name text NOT NULL, + investor_type text NOT NULL, + status text NOT NULL, + CONSTRAINT imz_investors_pk PRIMARY KEY (id), + CONSTRAINT imz_investors_check_01 CHECK (version_no > 0), + CONSTRAINT imz_investors_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_investors_check_03 CHECK (investor_type IN ('domestic','foreign','institutional','individual')), + CONSTRAINT imz_investors_check_04 CHECK (status IN ('prospect','active','inactive')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.contacts ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + full_name text NOT NULL, + email text, + phone text, + CONSTRAINT imz_contacts_pk PRIMARY KEY (id), + CONSTRAINT imz_contacts_check_01 CHECK (version_no > 0), + CONSTRAINT imz_contacts_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.investor_contacts ( + investor_id uuid NOT NULL CONSTRAINT imz_investor_contacts_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE, + contact_id uuid NOT NULL CONSTRAINT imz_investor_contacts_contact_id_fk REFERENCES public.contacts(id) ON DELETE CASCADE ON UPDATE CASCADE, + is_primary boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_investor_contacts_pk PRIMARY KEY (investor_id, contact_id) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.leads ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + investor_id uuid CONSTRAINT imz_leads_investor_id_fk REFERENCES public.investors(id) ON DELETE SET NULL ON UPDATE CASCADE, + owner_user_id bigint CONSTRAINT imz_leads_owner_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + title text NOT NULL, + status text NOT NULL, + estimated_amount numeric(20,2), + currency char(3), + CONSTRAINT imz_leads_pk PRIMARY KEY (id), + CONSTRAINT imz_leads_check_01 CHECK (version_no > 0), + CONSTRAINT imz_leads_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_leads_check_03 CHECK (status IN ('new','qualified','disqualified','converted')), + CONSTRAINT imz_leads_check_04 CHECK ((estimated_amount IS NULL)=(currency IS NULL)), + CONSTRAINT imz_leads_check_05 CHECK (estimated_amount IS NULL OR estimated_amount >= 0), + CONSTRAINT imz_leads_check_06 CHECK (currency IS NULL OR currency ~ '^[A-Z]{3}$') +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.crm_opportunities ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + lead_id uuid CONSTRAINT imz_crm_opportunities_lead_id_fk REFERENCES public.leads(id) ON DELETE SET NULL ON UPDATE CASCADE, + project_id uuid CONSTRAINT imz_crm_opportunities_project_id_fk REFERENCES public.projects(id) ON DELETE SET NULL ON UPDATE CASCADE, + owner_user_id bigint CONSTRAINT imz_crm_opportunities_owner_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + stage text NOT NULL, + amount numeric(20,2), + currency char(3), + probability numeric(5,2), + CONSTRAINT imz_crm_opportunities_pk PRIMARY KEY (id), + CONSTRAINT imz_crm_opportunities_check_01 CHECK (version_no > 0), + CONSTRAINT imz_crm_opportunities_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_crm_opportunities_check_03 CHECK (stage IN ('discovery','assessment','negotiation','approved','lost')), + CONSTRAINT imz_crm_opportunities_check_04 CHECK (probability BETWEEN 0 AND 100), + CONSTRAINT imz_crm_opportunities_check_05 CHECK ((amount IS NULL)=(currency IS NULL)), + CONSTRAINT imz_crm_opportunities_check_06 CHECK (amount IS NULL OR amount >= 0), + CONSTRAINT imz_crm_opportunities_check_07 CHECK (currency IS NULL OR currency ~ '^[A-Z]{3}$') +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.activities ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + investor_id uuid CONSTRAINT imz_activities_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE, + opportunity_id uuid CONSTRAINT imz_activities_opportunity_id_fk REFERENCES public.crm_opportunities(id) ON DELETE SET NULL ON UPDATE CASCADE, + actor_user_id bigint CONSTRAINT imz_activities_actor_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + activity_type text NOT NULL, + occurred_at timestamptz NOT NULL, + summary text NOT NULL, + CONSTRAINT imz_activities_pk PRIMARY KEY (id), + CONSTRAINT imz_activities_check_01 CHECK (version_no > 0), + CONSTRAINT imz_activities_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_activities_check_03 CHECK (activity_type IN ('call','meeting','email','note')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.tasks ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + assignee_user_id bigint CONSTRAINT imz_tasks_assignee_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + opportunity_id uuid CONSTRAINT imz_tasks_opportunity_id_fk REFERENCES public.crm_opportunities(id) ON DELETE CASCADE ON UPDATE CASCADE, + title text NOT NULL, + status text NOT NULL, + due_at timestamptz, + CONSTRAINT imz_tasks_pk PRIMARY KEY (id), + CONSTRAINT imz_tasks_check_01 CHECK (version_no > 0), + CONSTRAINT imz_tasks_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_tasks_check_03 CHECK (status IN ('open','in_progress','done','cancelled')) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.tasks DROP CONSTRAINT IF EXISTS imz_tasks_assignee_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.tasks DROP CONSTRAINT IF EXISTS imz_tasks_opportunity_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.tasks DROP CONSTRAINT IF EXISTS imz_tasks_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.tasks DROP CONSTRAINT IF EXISTS imz_tasks_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.tasks DROP CONSTRAINT IF EXISTS imz_tasks_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.tasks DROP CONSTRAINT IF EXISTS imz_tasks_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.tasks RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.activities DROP CONSTRAINT IF EXISTS imz_activities_investor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.activities DROP CONSTRAINT IF EXISTS imz_activities_opportunity_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.activities DROP CONSTRAINT IF EXISTS imz_activities_actor_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.activities DROP CONSTRAINT IF EXISTS imz_activities_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.activities DROP CONSTRAINT IF EXISTS imz_activities_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.activities DROP CONSTRAINT IF EXISTS imz_activities_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.activities DROP CONSTRAINT IF EXISTS imz_activities_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.activities RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_lead_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_owner_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_check_05 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_check_06 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.crm_opportunities DROP CONSTRAINT IF EXISTS imz_crm_opportunities_check_07 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.crm_opportunities RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_investor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_owner_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_check_05 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.leads DROP CONSTRAINT IF EXISTS imz_leads_check_06 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.leads RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_contacts DROP CONSTRAINT IF EXISTS imz_investor_contacts_investor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_contacts DROP CONSTRAINT IF EXISTS imz_investor_contacts_contact_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_contacts DROP CONSTRAINT IF EXISTS imz_investor_contacts_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.investor_contacts RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.contacts DROP CONSTRAINT IF EXISTS imz_contacts_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.contacts DROP CONSTRAINT IF EXISTS imz_contacts_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.contacts DROP CONSTRAINT IF EXISTS imz_contacts_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.contacts RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investors DROP CONSTRAINT IF EXISTS imz_investors_organization_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investors DROP CONSTRAINT IF EXISTS imz_investors_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investors DROP CONSTRAINT IF EXISTS imz_investors_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investors DROP CONSTRAINT IF EXISTS imz_investors_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investors DROP CONSTRAINT IF EXISTS imz_investors_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investors DROP CONSTRAINT IF EXISTS imz_investors_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.investors RESTRICT; +SQL); + } +}; +``` + +### database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.investor_journeys ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + investor_id uuid NOT NULL CONSTRAINT imz_investor_journeys_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE, + opportunity_id uuid CONSTRAINT imz_investor_journeys_opportunity_id_fk REFERENCES public.crm_opportunities(id) ON DELETE SET NULL ON UPDATE CASCADE, + status text NOT NULL, + started_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_investor_journeys_pk PRIMARY KEY (id), + CONSTRAINT imz_investor_journeys_check_01 CHECK (version_no > 0), + CONSTRAINT imz_investor_journeys_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_investor_journeys_check_03 CHECK (status IN ('started','active','completed','cancelled')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.journey_steps ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + journey_id uuid NOT NULL CONSTRAINT imz_journey_steps_journey_id_fk REFERENCES public.investor_journeys(id) ON DELETE CASCADE ON UPDATE CASCADE, + step_code text NOT NULL, + sequence_no integer NOT NULL, + status text NOT NULL, + CONSTRAINT imz_journey_steps_pk PRIMARY KEY (id), + CONSTRAINT imz_journey_steps_check_01 CHECK (version_no > 0), + CONSTRAINT imz_journey_steps_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_journey_steps_check_03 CHECK (sequence_no>0), + CONSTRAINT imz_journey_steps_check_04 CHECK (status IN ('pending','active','completed','blocked')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.submissions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + journey_step_id uuid NOT NULL CONSTRAINT imz_submissions_journey_step_id_fk REFERENCES public.journey_steps(id) ON DELETE CASCADE ON UPDATE CASCADE, + submitted_by bigint CONSTRAINT imz_submissions_submitted_by_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + status text NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + submitted_at timestamptz, + CONSTRAINT imz_submissions_pk PRIMARY KEY (id), + CONSTRAINT imz_submissions_check_01 CHECK (version_no > 0), + CONSTRAINT imz_submissions_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_submissions_check_03 CHECK (status IN ('draft','submitted','accepted','rejected')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.aftercare_plans ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + investor_id uuid NOT NULL CONSTRAINT imz_aftercare_plans_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE, + project_id uuid CONSTRAINT imz_aftercare_plans_project_id_fk REFERENCES public.projects(id) ON DELETE SET NULL ON UPDATE CASCADE, + owner_user_id bigint CONSTRAINT imz_aftercare_plans_owner_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + status text NOT NULL, + review_date date, + CONSTRAINT imz_aftercare_plans_pk PRIMARY KEY (id), + CONSTRAINT imz_aftercare_plans_check_01 CHECK (version_no > 0), + CONSTRAINT imz_aftercare_plans_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_aftercare_plans_check_03 CHECK (status IN ('draft','active','closed')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.constraints ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + aftercare_plan_id uuid NOT NULL CONSTRAINT imz_constraints_aftercare_plan_id_fk REFERENCES public.aftercare_plans(id) ON DELETE CASCADE ON UPDATE CASCADE, + category text NOT NULL, + description text NOT NULL, + severity text NOT NULL, + status text NOT NULL, + CONSTRAINT imz_constraints_pk PRIMARY KEY (id), + CONSTRAINT imz_constraints_check_01 CHECK (version_no > 0), + CONSTRAINT imz_constraints_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_constraints_check_03 CHECK (severity IN ('low','medium','high','critical')), + CONSTRAINT imz_constraints_check_04 CHECK (status IN ('open','mitigating','resolved')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.escalations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + constraint_id uuid NOT NULL CONSTRAINT imz_escalations_constraint_id_fk REFERENCES public.constraints(id) ON DELETE CASCADE ON UPDATE CASCADE, + escalated_by bigint CONSTRAINT imz_escalations_escalated_by_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE, + level text NOT NULL, + status text NOT NULL, + escalated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_escalations_pk PRIMARY KEY (id), + CONSTRAINT imz_escalations_check_01 CHECK (version_no > 0), + CONSTRAINT imz_escalations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_escalations_check_03 CHECK (level IN ('operational','management','executive')), + CONSTRAINT imz_escalations_check_04 CHECK (status IN ('open','acknowledged','closed')) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.escalations DROP CONSTRAINT IF EXISTS imz_escalations_constraint_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.escalations DROP CONSTRAINT IF EXISTS imz_escalations_escalated_by_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.escalations DROP CONSTRAINT IF EXISTS imz_escalations_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.escalations DROP CONSTRAINT IF EXISTS imz_escalations_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.escalations DROP CONSTRAINT IF EXISTS imz_escalations_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.escalations DROP CONSTRAINT IF EXISTS imz_escalations_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.escalations DROP CONSTRAINT IF EXISTS imz_escalations_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.escalations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.constraints DROP CONSTRAINT IF EXISTS imz_constraints_aftercare_plan_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.constraints DROP CONSTRAINT IF EXISTS imz_constraints_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.constraints DROP CONSTRAINT IF EXISTS imz_constraints_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.constraints DROP CONSTRAINT IF EXISTS imz_constraints_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.constraints DROP CONSTRAINT IF EXISTS imz_constraints_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.constraints DROP CONSTRAINT IF EXISTS imz_constraints_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.constraints RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_plans DROP CONSTRAINT IF EXISTS imz_aftercare_plans_investor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_plans DROP CONSTRAINT IF EXISTS imz_aftercare_plans_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_plans DROP CONSTRAINT IF EXISTS imz_aftercare_plans_owner_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_plans DROP CONSTRAINT IF EXISTS imz_aftercare_plans_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_plans DROP CONSTRAINT IF EXISTS imz_aftercare_plans_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_plans DROP CONSTRAINT IF EXISTS imz_aftercare_plans_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_plans DROP CONSTRAINT IF EXISTS imz_aftercare_plans_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.aftercare_plans RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.submissions DROP CONSTRAINT IF EXISTS imz_submissions_journey_step_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.submissions DROP CONSTRAINT IF EXISTS imz_submissions_submitted_by_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.submissions DROP CONSTRAINT IF EXISTS imz_submissions_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.submissions DROP CONSTRAINT IF EXISTS imz_submissions_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.submissions DROP CONSTRAINT IF EXISTS imz_submissions_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.submissions DROP CONSTRAINT IF EXISTS imz_submissions_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.submissions RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_steps DROP CONSTRAINT IF EXISTS imz_journey_steps_journey_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_steps DROP CONSTRAINT IF EXISTS imz_journey_steps_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_steps DROP CONSTRAINT IF EXISTS imz_journey_steps_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_steps DROP CONSTRAINT IF EXISTS imz_journey_steps_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_steps DROP CONSTRAINT IF EXISTS imz_journey_steps_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_steps DROP CONSTRAINT IF EXISTS imz_journey_steps_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.journey_steps RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_journeys DROP CONSTRAINT IF EXISTS imz_investor_journeys_investor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_journeys DROP CONSTRAINT IF EXISTS imz_investor_journeys_opportunity_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_journeys DROP CONSTRAINT IF EXISTS imz_investor_journeys_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_journeys DROP CONSTRAINT IF EXISTS imz_investor_journeys_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_journeys DROP CONSTRAINT IF EXISTS imz_investor_journeys_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_journeys DROP CONSTRAINT IF EXISTS imz_investor_journeys_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.investor_journeys RESTRICT; +SQL); + } +}; +``` + +### database/migrations/2026_09_24_009000_investmoz_education.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.skills ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + code text NOT NULL, + name text NOT NULL, + CONSTRAINT imz_skills_pk PRIMARY KEY (id), + CONSTRAINT imz_skills_check_01 CHECK (version_no > 0), + CONSTRAINT imz_skills_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.occupations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + code text NOT NULL, + name text NOT NULL, + CONSTRAINT imz_occupations_pk PRIMARY KEY (id), + CONSTRAINT imz_occupations_check_01 CHECK (version_no > 0), + CONSTRAINT imz_occupations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.training_needs ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + project_id uuid CONSTRAINT imz_training_needs_project_id_fk REFERENCES public.projects(id) ON DELETE SET NULL ON UPDATE CASCADE, + occupation_id uuid CONSTRAINT imz_training_needs_occupation_id_fk REFERENCES public.occupations(id) ON DELETE RESTRICT ON UPDATE CASCADE, + skill_id uuid CONSTRAINT imz_training_needs_skill_id_fk REFERENCES public.skills(id) ON DELETE RESTRICT ON UPDATE CASCADE, + quantity integer NOT NULL, + priority text NOT NULL, + CONSTRAINT imz_training_needs_pk PRIMARY KEY (id), + CONSTRAINT imz_training_needs_check_01 CHECK (version_no > 0), + CONSTRAINT imz_training_needs_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_training_needs_check_03 CHECK (quantity>0), + CONSTRAINT imz_training_needs_check_04 CHECK (priority IN ('Must','Should','Could')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.training_providers ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + organization_id uuid CONSTRAINT imz_training_providers_organization_id_fk REFERENCES public.organizations(id) ON DELETE SET NULL ON UPDATE CASCADE, + name text NOT NULL, + status text NOT NULL, + CONSTRAINT imz_training_providers_pk PRIMARY KEY (id), + CONSTRAINT imz_training_providers_check_01 CHECK (version_no > 0), + CONSTRAINT imz_training_providers_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_training_providers_check_03 CHECK (status IN ('candidate','approved','inactive')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.courses ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + provider_id uuid NOT NULL CONSTRAINT imz_courses_provider_id_fk REFERENCES public.training_providers(id) ON DELETE CASCADE ON UPDATE CASCADE, + code text NOT NULL, + title text NOT NULL, + status text NOT NULL, + CONSTRAINT imz_courses_pk PRIMARY KEY (id), + CONSTRAINT imz_courses_check_01 CHECK (version_no > 0), + CONSTRAINT imz_courses_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_courses_check_03 CHECK (status IN ('draft','published','retired')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.education_opportunities ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + course_id uuid NOT NULL CONSTRAINT imz_education_opportunities_course_id_fk REFERENCES public.courses(id) ON DELETE CASCADE ON UPDATE CASCADE, + geo_location_id uuid CONSTRAINT imz_education_opportunities_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE SET NULL ON UPDATE CASCADE, + start_date date NOT NULL, + end_date date, + capacity integer, + status text NOT NULL, + CONSTRAINT imz_education_opportunities_pk PRIMARY KEY (id), + CONSTRAINT imz_education_opportunities_check_01 CHECK (version_no > 0), + CONSTRAINT imz_education_opportunities_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_education_opportunities_check_03 CHECK (capacity>=0), + CONSTRAINT imz_education_opportunities_check_04 CHECK (status IN ('planned','open','closed','cancelled')), + CONSTRAINT imz_education_opportunities_check_05 CHECK (end_date IS NULL OR end_date>=start_date) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_course_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_geo_location_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_opportunities DROP CONSTRAINT IF EXISTS imz_education_opportunities_check_05 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.education_opportunities RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.courses DROP CONSTRAINT IF EXISTS imz_courses_provider_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.courses DROP CONSTRAINT IF EXISTS imz_courses_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.courses DROP CONSTRAINT IF EXISTS imz_courses_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.courses DROP CONSTRAINT IF EXISTS imz_courses_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.courses DROP CONSTRAINT IF EXISTS imz_courses_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.courses RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_providers DROP CONSTRAINT IF EXISTS imz_training_providers_organization_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_providers DROP CONSTRAINT IF EXISTS imz_training_providers_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_providers DROP CONSTRAINT IF EXISTS imz_training_providers_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_providers DROP CONSTRAINT IF EXISTS imz_training_providers_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_providers DROP CONSTRAINT IF EXISTS imz_training_providers_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.training_providers RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_occupation_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_skill_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.training_needs DROP CONSTRAINT IF EXISTS imz_training_needs_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.training_needs RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.occupations DROP CONSTRAINT IF EXISTS imz_occupations_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.occupations DROP CONSTRAINT IF EXISTS imz_occupations_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.occupations DROP CONSTRAINT IF EXISTS imz_occupations_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.occupations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.skills DROP CONSTRAINT IF EXISTS imz_skills_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.skills DROP CONSTRAINT IF EXISTS imz_skills_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.skills DROP CONSTRAINT IF EXISTS imz_skills_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.skills RESTRICT; +SQL); + } +}; +``` + +### database/migrations/2026_09_24_009500_investmoz_document_links.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.project_documents ( + project_id uuid NOT NULL CONSTRAINT imz_project_documents_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE, + document_id uuid NOT NULL CONSTRAINT imz_project_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_project_documents_pk PRIMARY KEY (project_id, document_id) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.investor_documents ( + investor_id uuid NOT NULL CONSTRAINT imz_investor_documents_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE, + document_id uuid NOT NULL CONSTRAINT imz_investor_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_investor_documents_pk PRIMARY KEY (investor_id, document_id) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.journey_documents ( + journey_id uuid NOT NULL CONSTRAINT imz_journey_documents_journey_id_fk REFERENCES public.investor_journeys(id) ON DELETE CASCADE ON UPDATE CASCADE, + document_id uuid NOT NULL CONSTRAINT imz_journey_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_journey_documents_pk PRIMARY KEY (journey_id, document_id) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.aftercare_documents ( + aftercare_plan_id uuid NOT NULL CONSTRAINT imz_aftercare_documents_aftercare_plan_id_fk REFERENCES public.aftercare_plans(id) ON DELETE CASCADE ON UPDATE CASCADE, + document_id uuid NOT NULL CONSTRAINT imz_aftercare_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_aftercare_documents_pk PRIMARY KEY (aftercare_plan_id, document_id) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.education_documents ( + education_opportunity_id uuid NOT NULL CONSTRAINT imz_education_documents_education_opportunity_id_fk REFERENCES public.education_opportunities(id) ON DELETE CASCADE ON UPDATE CASCADE, + document_id uuid NOT NULL CONSTRAINT imz_education_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT imz_education_documents_pk PRIMARY KEY (education_opportunity_id, document_id) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_documents DROP CONSTRAINT IF EXISTS imz_education_documents_education_opportunity_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_documents DROP CONSTRAINT IF EXISTS imz_education_documents_document_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.education_documents DROP CONSTRAINT IF EXISTS imz_education_documents_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.education_documents RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_documents DROP CONSTRAINT IF EXISTS imz_aftercare_documents_aftercare_plan_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_documents DROP CONSTRAINT IF EXISTS imz_aftercare_documents_document_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.aftercare_documents DROP CONSTRAINT IF EXISTS imz_aftercare_documents_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.aftercare_documents RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_documents DROP CONSTRAINT IF EXISTS imz_journey_documents_journey_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_documents DROP CONSTRAINT IF EXISTS imz_journey_documents_document_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.journey_documents DROP CONSTRAINT IF EXISTS imz_journey_documents_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.journey_documents RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_documents DROP CONSTRAINT IF EXISTS imz_investor_documents_investor_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_documents DROP CONSTRAINT IF EXISTS imz_investor_documents_document_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.investor_documents DROP CONSTRAINT IF EXISTS imz_investor_documents_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.investor_documents RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_documents DROP CONSTRAINT IF EXISTS imz_project_documents_project_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_documents DROP CONSTRAINT IF EXISTS imz_project_documents_document_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.project_documents DROP CONSTRAINT IF EXISTS imz_project_documents_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.project_documents RESTRICT; +SQL); + } +}; +``` + +### database/migrations/2026_09_24_010000_investmoz_governance_ai.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.audit_events ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + actor_user_id bigint CONSTRAINT imz_audit_events_actor_user_id_fk REFERENCES public.users(id) ON DELETE RESTRICT ON UPDATE RESTRICT, + entity_type text NOT NULL, + entity_id uuid, + action text NOT NULL, + occurred_at timestamptz NOT NULL DEFAULT now(), + before_data jsonb, + after_data jsonb, + CONSTRAINT imz_audit_events_pk PRIMARY KEY (id), + CONSTRAINT imz_audit_events_check_01 CHECK (data_classification IN ('public','internal','confidential','restricted')) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.audit_events DROP CONSTRAINT IF EXISTS imz_audit_events_actor_user_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.audit_events DROP CONSTRAINT IF EXISTS imz_audit_events_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.audit_events DROP CONSTRAINT IF EXISTS imz_audit_events_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.audit_events RESTRICT; +SQL); + } +}; +``` + +### database/migrations/2026_09_24_011000_investmoz_integrations.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TABLE public.outbox_events ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + aggregate_type text NOT NULL, + aggregate_id uuid NOT NULL, + event_type text NOT NULL, + payload jsonb NOT NULL, + status text NOT NULL, + available_at timestamptz NOT NULL DEFAULT now(), + published_at timestamptz, + CONSTRAINT imz_outbox_events_pk PRIMARY KEY (id), + CONSTRAINT imz_outbox_events_check_01 CHECK (version_no > 0), + CONSTRAINT imz_outbox_events_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_outbox_events_check_03 CHECK (status IN ('pending','published','failed')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.inbox_events ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + message_id text NOT NULL, + event_type text NOT NULL, + payload jsonb NOT NULL, + status text NOT NULL, + received_at timestamptz NOT NULL DEFAULT now(), + processed_at timestamptz, + CONSTRAINT imz_inbox_events_pk PRIMARY KEY (id), + CONSTRAINT imz_inbox_events_check_01 CHECK (version_no > 0), + CONSTRAINT imz_inbox_events_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_inbox_events_check_03 CHECK (status IN ('received','processed','failed')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.sync_runs ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + integration_name text NOT NULL, + direction text NOT NULL, + status text NOT NULL, + started_at timestamptz NOT NULL DEFAULT now(), + ended_at timestamptz, + totals jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT imz_sync_runs_pk PRIMARY KEY (id), + CONSTRAINT imz_sync_runs_check_01 CHECK (version_no > 0), + CONSTRAINT imz_sync_runs_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_sync_runs_check_03 CHECK (direction IN ('inbound','outbound')), + CONSTRAINT imz_sync_runs_check_04 CHECK (status IN ('running','succeeded','failed','cancelled')) +); +SQL); + + DB::statement(<<<'SQL' +CREATE TABLE public.reconciliation_issues ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source_system text NOT NULL DEFAULT 'investmoz', + external_id text, + version_no integer NOT NULL DEFAULT 1, + data_classification text NOT NULL DEFAULT 'internal', + correlation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + sync_run_id uuid NOT NULL CONSTRAINT imz_reconciliation_issues_sync_run_id_fk REFERENCES public.sync_runs(id) ON DELETE CASCADE ON UPDATE CASCADE, + entity_type text NOT NULL, + external_id_ref text, + issue_type text NOT NULL, + severity text NOT NULL, + status text NOT NULL, + details jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT imz_reconciliation_issues_pk PRIMARY KEY (id), + CONSTRAINT imz_reconciliation_issues_check_01 CHECK (version_no > 0), + CONSTRAINT imz_reconciliation_issues_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')), + CONSTRAINT imz_reconciliation_issues_check_03 CHECK (issue_type IN ('missing','duplicate','orphan','checksum','geometry','value')), + CONSTRAINT imz_reconciliation_issues_check_04 CHECK (severity IN ('warning','error')), + CONSTRAINT imz_reconciliation_issues_check_05 CHECK (status IN ('open','accepted','resolved')) +); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.reconciliation_issues DROP CONSTRAINT IF EXISTS imz_reconciliation_issues_sync_run_id_fk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.reconciliation_issues DROP CONSTRAINT IF EXISTS imz_reconciliation_issues_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.reconciliation_issues DROP CONSTRAINT IF EXISTS imz_reconciliation_issues_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.reconciliation_issues DROP CONSTRAINT IF EXISTS imz_reconciliation_issues_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.reconciliation_issues DROP CONSTRAINT IF EXISTS imz_reconciliation_issues_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.reconciliation_issues DROP CONSTRAINT IF EXISTS imz_reconciliation_issues_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.reconciliation_issues DROP CONSTRAINT IF EXISTS imz_reconciliation_issues_check_05 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.reconciliation_issues RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.sync_runs DROP CONSTRAINT IF EXISTS imz_sync_runs_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.sync_runs DROP CONSTRAINT IF EXISTS imz_sync_runs_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.sync_runs DROP CONSTRAINT IF EXISTS imz_sync_runs_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.sync_runs DROP CONSTRAINT IF EXISTS imz_sync_runs_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.sync_runs DROP CONSTRAINT IF EXISTS imz_sync_runs_check_04 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.sync_runs RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.inbox_events DROP CONSTRAINT IF EXISTS imz_inbox_events_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.inbox_events DROP CONSTRAINT IF EXISTS imz_inbox_events_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.inbox_events DROP CONSTRAINT IF EXISTS imz_inbox_events_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.inbox_events DROP CONSTRAINT IF EXISTS imz_inbox_events_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.inbox_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.outbox_events DROP CONSTRAINT IF EXISTS imz_outbox_events_pk RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.outbox_events DROP CONSTRAINT IF EXISTS imz_outbox_events_check_01 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.outbox_events DROP CONSTRAINT IF EXISTS imz_outbox_events_check_02 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +ALTER TABLE IF EXISTS public.outbox_events DROP CONSTRAINT IF EXISTS imz_outbox_events_check_03 RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TABLE IF EXISTS public.outbox_events RESTRICT; +SQL); + } +}; +``` + +### database/migrations/2026_09_24_012000_investmoz_indexes.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_organizations_source_system_external_id_uq ON public.organizations USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_organizations_updated_at_idx ON public.organizations USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_memberships_user_id_idx ON public.memberships USING btree (user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_memberships_organization_id_idx ON public.memberships USING btree (organization_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_memberships_user_id_organization_id_uq ON public.memberships USING btree (user_id, organization_id) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_memberships_source_system_external_id_uq ON public.memberships USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_memberships_updated_at_idx ON public.memberships USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_consents_user_id_idx ON public.consents USING btree (user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_consents_source_system_external_id_uq ON public.consents USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_consents_updated_at_idx ON public.consents USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_consents_status_idx ON public.consents USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_documents_storage_key_uq ON public.documents USING btree (storage_key); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_documents_source_system_external_id_uq ON public.documents USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_documents_updated_at_idx ON public.documents USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_documents_status_idx ON public.documents USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_geo_locations_parent_id_idx ON public.geo_locations USING btree (parent_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_geo_locations_source_system_external_id_uq ON public.geo_locations USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_geo_locations_location_type_code_uq ON public.geo_locations USING btree (location_type, code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_geo_locations_updated_at_idx ON public.geo_locations USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_geo_geometries_geo_location_id_idx ON public.geo_geometries USING btree (geo_location_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_geo_geometries_geo_location_id_geometry_type_uq ON public.geo_geometries USING btree (geo_location_id, geometry_type) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_geo_geometries_source_system_external_id_uq ON public.geo_geometries USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_geo_geometries_geom_idx ON public.geo_geometries USING gist (geom); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_geo_geometries_updated_at_idx ON public.geo_geometries USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_projects_code_uq ON public.projects USING btree (code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_projects_source_system_external_id_uq ON public.projects USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_projects_updated_at_idx ON public.projects USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_projects_status_idx ON public.projects USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_project_locations_project_id_idx ON public.project_locations USING btree (project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_project_locations_geo_location_id_idx ON public.project_locations USING btree (geo_location_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_project_locations_project_id_geo_location_id_role_uq ON public.project_locations USING btree (project_id, geo_location_id, role) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_project_locations_source_system_external_id_uq ON public.project_locations USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_project_locations_updated_at_idx ON public.project_locations USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_economic_clusters_code_uq ON public.economic_clusters USING btree (code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_economic_clusters_source_system_external_id_uq ON public.economic_clusters USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_economic_clusters_updated_at_idx ON public.economic_clusters USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_economic_clusters_status_idx ON public.economic_clusters USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_value_chains_cluster_id_idx ON public.value_chains USING btree (cluster_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_value_chains_cluster_id_code_uq ON public.value_chains USING btree (cluster_id, code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_value_chains_source_system_external_id_uq ON public.value_chains USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_value_chains_updated_at_idx ON public.value_chains USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_value_chain_links_from_value_chain_id_idx ON public.value_chain_links USING btree (from_value_chain_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_value_chain_links_to_value_chain_id_idx ON public.value_chain_links USING btree (to_value_chain_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c ON public.value_chain_links USING btree (from_value_chain_id, to_value_chain_id, link_type) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_value_chain_links_source_system_external_id_uq ON public.value_chain_links USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_value_chain_links_updated_at_idx ON public.value_chain_links USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_cluster_projects_cluster_id_idx ON public.cluster_projects USING btree (cluster_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_cluster_projects_project_id_idx ON public.cluster_projects USING btree (project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_cluster_projects_cluster_id_project_id_uq ON public.cluster_projects USING btree (cluster_id, project_id) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_cluster_projects_source_system_external_id_uq ON public.cluster_projects USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_cluster_projects_updated_at_idx ON public.cluster_projects USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_anchor_project_links_anchor_project_id_idx ON public.anchor_project_links USING btree (anchor_project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_anchor_project_links_cluster_id_idx ON public.anchor_project_links USING btree (cluster_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_anchor_project_links_anchor_project_id_cluster_id_uq ON public.anchor_project_links USING btree (anchor_project_id, cluster_id) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_anchor_project_links_source_system_external_id_uq ON public.anchor_project_links USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_anchor_project_links_updated_at_idx ON public.anchor_project_links USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_complementary_project_links_anchor_project_id_idx ON public.complementary_project_links USING btree (anchor_project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_complementary_project_links_complementary_project_id_idx ON public.complementary_project_links USING btree (complementary_project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_complementary_project_links_anchor_project_id_co_90eddcd25a ON public.complementary_project_links USING btree (anchor_project_id, complementary_project_id, relation_type) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_complementary_project_links_source_system_external_id_uq ON public.complementary_project_links USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_complementary_project_links_updated_at_idx ON public.complementary_project_links USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_development_corridors_code_uq ON public.development_corridors USING btree (code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_development_corridors_source_system_external_id_uq ON public.development_corridors USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_development_corridors_updated_at_idx ON public.development_corridors USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_development_corridors_status_idx ON public.development_corridors USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_locations_corridor_id_idx ON public.corridor_locations USING btree (corridor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_locations_geo_location_id_idx ON public.corridor_locations USING btree (geo_location_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_corridor_locations_corridor_id_geo_location_id_uq ON public.corridor_locations USING btree (corridor_id, geo_location_id) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_corridor_locations_corridor_id_sequence_no_uq ON public.corridor_locations USING btree (corridor_id, sequence_no) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_corridor_locations_source_system_external_id_uq ON public.corridor_locations USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_locations_updated_at_idx ON public.corridor_locations USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_projects_corridor_id_idx ON public.corridor_projects USING btree (corridor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_projects_project_id_idx ON public.corridor_projects USING btree (project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_corridor_projects_corridor_id_project_id_uq ON public.corridor_projects USING btree (corridor_id, project_id) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_corridor_projects_source_system_external_id_uq ON public.corridor_projects USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_projects_updated_at_idx ON public.corridor_projects USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_clusters_corridor_id_idx ON public.corridor_clusters USING btree (corridor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_clusters_cluster_id_idx ON public.corridor_clusters USING btree (cluster_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_corridor_clusters_corridor_id_cluster_id_uq ON public.corridor_clusters USING btree (corridor_id, cluster_id) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_corridor_clusters_source_system_external_id_uq ON public.corridor_clusters USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_corridor_clusters_updated_at_idx ON public.corridor_clusters USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investors_organization_id_idx ON public.investors USING btree (organization_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_investors_source_system_external_id_uq ON public.investors USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investors_updated_at_idx ON public.investors USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investors_status_idx ON public.investors USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_contacts_source_system_external_id_uq ON public.contacts USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_contacts_updated_at_idx ON public.contacts USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_contacts_investor_id_idx ON public.investor_contacts USING btree (investor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_contacts_contact_id_idx ON public.investor_contacts USING btree (contact_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_contacts_updated_at_idx ON public.investor_contacts USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_leads_investor_id_idx ON public.leads USING btree (investor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_leads_owner_user_id_idx ON public.leads USING btree (owner_user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_leads_source_system_external_id_uq ON public.leads USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_leads_updated_at_idx ON public.leads USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_leads_status_idx ON public.leads USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_crm_opportunities_lead_id_idx ON public.crm_opportunities USING btree (lead_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_crm_opportunities_project_id_idx ON public.crm_opportunities USING btree (project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_crm_opportunities_owner_user_id_idx ON public.crm_opportunities USING btree (owner_user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_crm_opportunities_source_system_external_id_uq ON public.crm_opportunities USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_crm_opportunities_updated_at_idx ON public.crm_opportunities USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_activities_investor_id_idx ON public.activities USING btree (investor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_activities_opportunity_id_idx ON public.activities USING btree (opportunity_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_activities_actor_user_id_idx ON public.activities USING btree (actor_user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_activities_source_system_external_id_uq ON public.activities USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_activities_updated_at_idx ON public.activities USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_tasks_assignee_user_id_idx ON public.tasks USING btree (assignee_user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_tasks_opportunity_id_idx ON public.tasks USING btree (opportunity_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_tasks_source_system_external_id_uq ON public.tasks USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_tasks_updated_at_idx ON public.tasks USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_tasks_status_idx ON public.tasks USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_journeys_investor_id_idx ON public.investor_journeys USING btree (investor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_journeys_opportunity_id_idx ON public.investor_journeys USING btree (opportunity_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_investor_journeys_source_system_external_id_uq ON public.investor_journeys USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_journeys_updated_at_idx ON public.investor_journeys USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_journeys_status_idx ON public.investor_journeys USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_journey_steps_journey_id_idx ON public.journey_steps USING btree (journey_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_journey_steps_journey_id_sequence_no_uq ON public.journey_steps USING btree (journey_id, sequence_no) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_journey_steps_journey_id_step_code_uq ON public.journey_steps USING btree (journey_id, step_code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_journey_steps_source_system_external_id_uq ON public.journey_steps USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_journey_steps_updated_at_idx ON public.journey_steps USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_journey_steps_status_idx ON public.journey_steps USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_submissions_journey_step_id_idx ON public.submissions USING btree (journey_step_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_submissions_submitted_by_idx ON public.submissions USING btree (submitted_by); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_submissions_source_system_external_id_uq ON public.submissions USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_submissions_updated_at_idx ON public.submissions USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_submissions_status_idx ON public.submissions USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_aftercare_plans_investor_id_idx ON public.aftercare_plans USING btree (investor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_aftercare_plans_project_id_idx ON public.aftercare_plans USING btree (project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_aftercare_plans_owner_user_id_idx ON public.aftercare_plans USING btree (owner_user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_aftercare_plans_source_system_external_id_uq ON public.aftercare_plans USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_aftercare_plans_updated_at_idx ON public.aftercare_plans USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_aftercare_plans_status_idx ON public.aftercare_plans USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_constraints_aftercare_plan_id_idx ON public.constraints USING btree (aftercare_plan_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_constraints_source_system_external_id_uq ON public.constraints USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_constraints_updated_at_idx ON public.constraints USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_constraints_status_idx ON public.constraints USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_escalations_constraint_id_idx ON public.escalations USING btree (constraint_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_escalations_escalated_by_idx ON public.escalations USING btree (escalated_by); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_escalations_source_system_external_id_uq ON public.escalations USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_escalations_updated_at_idx ON public.escalations USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_escalations_status_idx ON public.escalations USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_skills_code_uq ON public.skills USING btree (code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_skills_source_system_external_id_uq ON public.skills USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_skills_updated_at_idx ON public.skills USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_occupations_code_uq ON public.occupations USING btree (code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_occupations_source_system_external_id_uq ON public.occupations USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_occupations_updated_at_idx ON public.occupations USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_training_needs_project_id_idx ON public.training_needs USING btree (project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_training_needs_occupation_id_idx ON public.training_needs USING btree (occupation_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_training_needs_skill_id_idx ON public.training_needs USING btree (skill_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_training_needs_source_system_external_id_uq ON public.training_needs USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_training_needs_updated_at_idx ON public.training_needs USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_training_providers_organization_id_idx ON public.training_providers USING btree (organization_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_training_providers_source_system_external_id_uq ON public.training_providers USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_training_providers_updated_at_idx ON public.training_providers USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_training_providers_status_idx ON public.training_providers USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_courses_provider_id_idx ON public.courses USING btree (provider_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_courses_provider_id_code_uq ON public.courses USING btree (provider_id, code) WHERE deleted_at IS NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_courses_source_system_external_id_uq ON public.courses USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_courses_updated_at_idx ON public.courses USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_courses_status_idx ON public.courses USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_education_opportunities_course_id_idx ON public.education_opportunities USING btree (course_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_education_opportunities_geo_location_id_idx ON public.education_opportunities USING btree (geo_location_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_education_opportunities_source_system_external_id_uq ON public.education_opportunities USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_education_opportunities_updated_at_idx ON public.education_opportunities USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_education_opportunities_status_idx ON public.education_opportunities USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_project_documents_project_id_idx ON public.project_documents USING btree (project_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_project_documents_document_id_idx ON public.project_documents USING btree (document_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_documents_investor_id_idx ON public.investor_documents USING btree (investor_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_investor_documents_document_id_idx ON public.investor_documents USING btree (document_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_journey_documents_journey_id_idx ON public.journey_documents USING btree (journey_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_journey_documents_document_id_idx ON public.journey_documents USING btree (document_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_aftercare_documents_aftercare_plan_id_idx ON public.aftercare_documents USING btree (aftercare_plan_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_aftercare_documents_document_id_idx ON public.aftercare_documents USING btree (document_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_education_documents_education_opportunity_id_idx ON public.education_documents USING btree (education_opportunity_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_education_documents_document_id_idx ON public.education_documents USING btree (document_id); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_audit_events_actor_user_id_idx ON public.audit_events USING btree (actor_user_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_audit_events_source_system_external_id_uq ON public.audit_events USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_outbox_events_source_system_external_id_uq ON public.outbox_events USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_outbox_events_updated_at_idx ON public.outbox_events USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_outbox_events_status_idx ON public.outbox_events USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_inbox_events_message_id_uq ON public.inbox_events USING btree (message_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_inbox_events_source_system_external_id_uq ON public.inbox_events USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_inbox_events_updated_at_idx ON public.inbox_events USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_inbox_events_status_idx ON public.inbox_events USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_sync_runs_source_system_external_id_uq ON public.sync_runs USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_sync_runs_updated_at_idx ON public.sync_runs USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_sync_runs_status_idx ON public.sync_runs USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_reconciliation_issues_sync_run_id_idx ON public.reconciliation_issues USING btree (sync_run_id); +SQL); + + DB::statement(<<<'SQL' +CREATE UNIQUE INDEX imz_reconciliation_issues_source_system_external_id_uq ON public.reconciliation_issues USING btree (source_system, external_id) WHERE external_id IS NOT NULL; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_reconciliation_issues_updated_at_idx ON public.reconciliation_issues USING btree (updated_at); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_reconciliation_issues_status_idx ON public.reconciliation_issues USING btree (status); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_geo_geometries_properties_idx ON public.geo_geometries USING gin (properties); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_projects_metadata_idx ON public.projects USING gin (metadata); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_submissions_payload_idx ON public.submissions USING gin (payload); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_audit_events_after_data_idx ON public.audit_events USING gin (after_data); +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_outbox_events_status_available_at_idx ON public.outbox_events USING btree (status, available_at) WHERE status = 'pending'; +SQL); + + DB::statement(<<<'SQL' +CREATE INDEX imz_reconciliation_issues_sync_run_id_status_idx ON public.reconciliation_issues USING btree (sync_run_id, status); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_reconciliation_issues_sync_run_id_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_outbox_events_status_available_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_audit_events_after_data_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_submissions_payload_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_projects_metadata_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_geometries_properties_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_reconciliation_issues_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_reconciliation_issues_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_reconciliation_issues_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_reconciliation_issues_sync_run_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_sync_runs_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_sync_runs_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_sync_runs_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_inbox_events_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_inbox_events_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_inbox_events_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_inbox_events_message_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_outbox_events_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_outbox_events_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_outbox_events_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_audit_events_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_audit_events_actor_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_education_documents_document_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_education_documents_education_opportunity_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_documents_document_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_documents_aftercare_plan_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_documents_document_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_documents_journey_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_documents_document_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_documents_investor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_project_documents_document_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_project_documents_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_education_opportunities_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_education_opportunities_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_education_opportunities_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_education_opportunities_geo_location_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_education_opportunities_course_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_courses_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_courses_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_courses_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_courses_provider_id_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_courses_provider_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_providers_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_providers_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_providers_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_providers_organization_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_needs_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_needs_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_needs_skill_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_needs_occupation_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_training_needs_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_occupations_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_occupations_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_occupations_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_skills_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_skills_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_skills_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_escalations_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_escalations_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_escalations_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_escalations_escalated_by_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_escalations_constraint_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_constraints_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_constraints_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_constraints_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_constraints_aftercare_plan_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_plans_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_plans_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_plans_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_plans_owner_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_plans_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_aftercare_plans_investor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_submissions_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_submissions_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_submissions_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_submissions_submitted_by_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_submissions_journey_step_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_steps_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_steps_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_steps_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_steps_journey_id_step_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_steps_journey_id_sequence_no_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_journey_steps_journey_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_journeys_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_journeys_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_journeys_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_journeys_opportunity_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_journeys_investor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_tasks_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_tasks_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_tasks_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_tasks_opportunity_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_tasks_assignee_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_activities_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_activities_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_activities_actor_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_activities_opportunity_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_activities_investor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_crm_opportunities_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_crm_opportunities_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_crm_opportunities_owner_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_crm_opportunities_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_crm_opportunities_lead_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_leads_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_leads_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_leads_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_leads_owner_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_leads_investor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_contacts_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_contacts_contact_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investor_contacts_investor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_contacts_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_contacts_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investors_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investors_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investors_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_investors_organization_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_clusters_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_clusters_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_clusters_corridor_id_cluster_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_clusters_cluster_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_clusters_corridor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_projects_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_projects_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_projects_corridor_id_project_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_projects_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_projects_corridor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_locations_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_locations_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_locations_corridor_id_sequence_no_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_locations_corridor_id_geo_location_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_locations_geo_location_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_corridor_locations_corridor_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_development_corridors_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_development_corridors_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_development_corridors_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_development_corridors_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_complementary_project_links_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_complementary_project_links_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_complementary_project_links_anchor_project_id_co_90eddcd25a RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_complementary_project_links_complementary_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_complementary_project_links_anchor_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_anchor_project_links_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_anchor_project_links_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_anchor_project_links_anchor_project_id_cluster_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_anchor_project_links_cluster_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_anchor_project_links_anchor_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_cluster_projects_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_cluster_projects_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_cluster_projects_cluster_id_project_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_cluster_projects_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_cluster_projects_cluster_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chain_links_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chain_links_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chain_links_to_value_chain_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chain_links_from_value_chain_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chains_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chains_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chains_cluster_id_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_value_chains_cluster_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_economic_clusters_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_economic_clusters_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_economic_clusters_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_economic_clusters_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_project_locations_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_project_locations_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_project_locations_project_id_geo_location_id_role_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_project_locations_geo_location_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_project_locations_project_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_projects_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_projects_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_projects_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_projects_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_geometries_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_geometries_geom_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_geometries_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_geometries_geo_location_id_geometry_type_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_geometries_geo_location_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_locations_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_locations_location_type_code_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_locations_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_geo_locations_parent_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_documents_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_documents_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_documents_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_documents_storage_key_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_consents_status_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_consents_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_consents_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_consents_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_memberships_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_memberships_source_system_external_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_memberships_user_id_organization_id_uq RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_memberships_organization_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_memberships_user_id_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_organizations_updated_at_idx RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP INDEX IF EXISTS public.imz_organizations_source_system_external_id_uq RESTRICT; +SQL); + } +}; +``` + +### database/migrations/2026_09_24_013000_investmoz_triggers.php + +```php +getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz requires PostgreSQL with PostGIS. Use the isolated pgsql test environment.'); + } + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_organizations_updated BEFORE UPDATE ON public.organizations FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_memberships_updated BEFORE UPDATE ON public.memberships FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_consents_updated BEFORE UPDATE ON public.consents FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_documents_updated BEFORE UPDATE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_geo_locations_updated BEFORE UPDATE ON public.geo_locations FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_geo_geometries_updated BEFORE UPDATE ON public.geo_geometries FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_projects_updated BEFORE UPDATE ON public.projects FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_project_locations_updated BEFORE UPDATE ON public.project_locations FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_economic_clusters_updated BEFORE UPDATE ON public.economic_clusters FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_value_chains_updated BEFORE UPDATE ON public.value_chains FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_value_chain_links_updated BEFORE UPDATE ON public.value_chain_links FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_cluster_projects_updated BEFORE UPDATE ON public.cluster_projects FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_anchor_project_links_updated BEFORE UPDATE ON public.anchor_project_links FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_complementary_project_links_updated BEFORE UPDATE ON public.complementary_project_links FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_development_corridors_updated BEFORE UPDATE ON public.development_corridors FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_corridor_locations_updated BEFORE UPDATE ON public.corridor_locations FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_corridor_projects_updated BEFORE UPDATE ON public.corridor_projects FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_corridor_clusters_updated BEFORE UPDATE ON public.corridor_clusters FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_investors_updated BEFORE UPDATE ON public.investors FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_contacts_updated BEFORE UPDATE ON public.contacts FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_investor_contacts_updated BEFORE UPDATE ON public.investor_contacts FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_leads_updated BEFORE UPDATE ON public.leads FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_crm_opportunities_updated BEFORE UPDATE ON public.crm_opportunities FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_activities_updated BEFORE UPDATE ON public.activities FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_tasks_updated BEFORE UPDATE ON public.tasks FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_investor_journeys_updated BEFORE UPDATE ON public.investor_journeys FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_journey_steps_updated BEFORE UPDATE ON public.journey_steps FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_submissions_updated BEFORE UPDATE ON public.submissions FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_aftercare_plans_updated BEFORE UPDATE ON public.aftercare_plans FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_constraints_updated BEFORE UPDATE ON public.constraints FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_escalations_updated BEFORE UPDATE ON public.escalations FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_skills_updated BEFORE UPDATE ON public.skills FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_occupations_updated BEFORE UPDATE ON public.occupations FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_training_needs_updated BEFORE UPDATE ON public.training_needs FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_training_providers_updated BEFORE UPDATE ON public.training_providers FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_courses_updated BEFORE UPDATE ON public.courses FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_education_opportunities_updated BEFORE UPDATE ON public.education_opportunities FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_outbox_events_updated BEFORE UPDATE ON public.outbox_events FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_inbox_events_updated BEFORE UPDATE ON public.inbox_events FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_sync_runs_updated BEFORE UPDATE ON public.sync_runs FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_reconciliation_issues_updated BEFORE UPDATE ON public.reconciliation_issues FOR EACH ROW EXECUTE FUNCTION public.investmoz_set_updated_at(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_audit_events_immutable BEFORE UPDATE OR DELETE OR TRUNCATE ON public.audit_events FOR EACH STATEMENT EXECUTE FUNCTION public.investmoz_reject_audit_change(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_outbox_events_envelope BEFORE UPDATE OR DELETE ON public.outbox_events FOR EACH ROW EXECUTE FUNCTION public.investmoz_protect_event_envelope(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_outbox_events_no_truncate BEFORE TRUNCATE ON public.outbox_events FOR EACH STATEMENT EXECUTE FUNCTION public.investmoz_reject_audit_change(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_inbox_events_envelope BEFORE UPDATE OR DELETE ON public.inbox_events FOR EACH ROW EXECUTE FUNCTION public.investmoz_protect_event_envelope(); +SQL); + + DB::statement(<<<'SQL' +CREATE TRIGGER imz_inbox_events_no_truncate BEFORE TRUNCATE ON public.inbox_events FOR EACH STATEMENT EXECUTE FUNCTION public.investmoz_reject_audit_change(); +SQL); + } + + public function down(): void + { + if (DB::connection()->getDriverName() !== 'pgsql') { + throw new RuntimeException('InvestMoz rollback requires PostgreSQL.'); + } + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_inbox_events_no_truncate ON public.inbox_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_inbox_events_envelope ON public.inbox_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_outbox_events_no_truncate ON public.outbox_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_outbox_events_envelope ON public.outbox_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_audit_events_immutable ON public.audit_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_reconciliation_issues_updated ON public.reconciliation_issues RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_sync_runs_updated ON public.sync_runs RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_inbox_events_updated ON public.inbox_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_outbox_events_updated ON public.outbox_events RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_education_opportunities_updated ON public.education_opportunities RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_courses_updated ON public.courses RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_training_providers_updated ON public.training_providers RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_training_needs_updated ON public.training_needs RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_occupations_updated ON public.occupations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_skills_updated ON public.skills RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_escalations_updated ON public.escalations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_constraints_updated ON public.constraints RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_aftercare_plans_updated ON public.aftercare_plans RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_submissions_updated ON public.submissions RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_journey_steps_updated ON public.journey_steps RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_investor_journeys_updated ON public.investor_journeys RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_tasks_updated ON public.tasks RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_activities_updated ON public.activities RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_crm_opportunities_updated ON public.crm_opportunities RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_leads_updated ON public.leads RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_investor_contacts_updated ON public.investor_contacts RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_contacts_updated ON public.contacts RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_investors_updated ON public.investors RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_corridor_clusters_updated ON public.corridor_clusters RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_corridor_projects_updated ON public.corridor_projects RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_corridor_locations_updated ON public.corridor_locations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_development_corridors_updated ON public.development_corridors RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_complementary_project_links_updated ON public.complementary_project_links RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_anchor_project_links_updated ON public.anchor_project_links RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_cluster_projects_updated ON public.cluster_projects RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_value_chain_links_updated ON public.value_chain_links RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_value_chains_updated ON public.value_chains RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_economic_clusters_updated ON public.economic_clusters RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_project_locations_updated ON public.project_locations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_projects_updated ON public.projects RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_geo_geometries_updated ON public.geo_geometries RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_geo_locations_updated ON public.geo_locations RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_documents_updated ON public.documents RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_consents_updated ON public.consents RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_memberships_updated ON public.memberships RESTRICT; +SQL); + + DB::statement(<<<'SQL' +DROP TRIGGER IF EXISTS imz_organizations_updated ON public.organizations RESTRICT; +SQL); + } +}; +``` + +## Migrations existentes preservadas + +### database/migrations/0001_01_01_000000_create_users_table.php + +```php +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; + +``` + +### database/migrations/0001_01_01_000001_create_cache_table.php + +```php +string('key')->primary(); + $table->mediumText('value'); + $table->bigInteger('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->bigInteger('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; + +``` + +### database/migrations/0001_01_01_000002_create_jobs_table.php + +```php +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedSmallInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('connection'); + $table->string('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; + +``` + +### database/migrations/2024_01_01_000000_create_passkeys_table.php + +```php +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('credential_id')->unique(); + $table->json('credential'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('passkeys'); + } +}; + +``` + +### database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php + +```php +text('two_factor_secret')->after('password')->nullable(); + $table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable(); + $table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'two_factor_secret', + 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + ]); + }); + } +}; + +``` + +### database/migrations/2026_01_01_000001_create_sectors_table.php + +```php +id(); + $table->string('name', 150); + $table->string('slug', 180)->unique(); + $table->string('short_description', 255)->nullable(); + $table->text('description')->nullable(); + $table->string('icon', 100)->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('slug'); + $table->index('is_active'); + }); + } + + public function down(): void + { + Schema::dropIfExists('sectors'); + } +}; + +``` + +### database/migrations/2026_01_01_000002_create_opportunities_table.php + +```php +id(); + + $table->foreignId('sector_id') + ->constrained('sectors') + ->cascadeOnUpdate() + ->restrictOnDelete(); + + $table->string('reference', 80)->unique(); + $table->string('title', 200); + $table->string('slug', 220)->unique(); + $table->string('summary', 500)->nullable(); + $table->longText('description')->nullable(); + + $table->string('province', 100)->nullable(); + $table->string('district', 100)->nullable(); + + $table->string('promoter_name', 200)->nullable(); + $table->string('promoter_type', 100)->nullable(); + + $table->unsignedTinyInteger('maturity_level')->nullable(); + $table->string('maturity_label', 120)->nullable(); + + $table->decimal('estimated_investment_amount', 18, 2)->nullable(); + $table->string('estimated_investment_currency', 10)->default('USD'); + $table->string('financing_modality', 120)->nullable(); + $table->text('expected_impact')->nullable(); + + $table->string('status', 50)->default('draft'); + $table->boolean('is_featured')->default(false); + + $table->timestamp('published_at')->nullable(); + $table->timestamp('last_validated_at')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + + $table->index('sector_id'); + $table->index('reference'); + $table->index('slug'); + $table->index('status'); + $table->index('is_featured'); + $table->index('province'); + $table->index('maturity_level'); + }); + } + + public function down(): void + { + Schema::dropIfExists('opportunities'); + } +}; + +``` + +### database/migrations/2026_01_01_000003_create_interest_requests_table.php + +```php +id(); + + $table->foreignId('opportunity_id') + ->nullable() + ->constrained('opportunities') + ->cascadeOnUpdate() + ->nullOnDelete(); + + $table->string('reference', 80)->unique(); + + $table->string('full_name', 150); + $table->string('email', 150); + $table->string('phone', 30)->nullable(); + + $table->string('investor_type', 80); + $table->string('country', 100); + $table->string('sector_interest', 150); + $table->string('opportunity_reference', 80)->nullable(); + $table->string('investment_range', 80)->nullable(); + + $table->text('message'); + + $table->boolean('privacy_consent')->default(false); + $table->timestamp('consented_at')->nullable(); + + $table->string('status', 50)->default('received'); + + $table->string('source_page', 255)->nullable(); + $table->ipAddress('ip_address')->nullable(); + $table->text('user_agent')->nullable(); + $table->json('metadata')->nullable(); + + $table->timestamp('reviewed_at')->nullable(); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + + $table->timestamps(); + $table->softDeletes(); + + $table->index('reference'); + $table->index('email'); + $table->index('status'); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('interest_requests'); + } +}; + +``` + +## Factories e seeders + +### app/Models/Investor.php + +```php + */ + 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 */ + protected function casts(): array + { + return [ + 'version_no' => 'integer', + 'created_at' => 'immutable_datetime', + 'updated_at' => 'immutable_datetime', + 'deleted_at' => 'immutable_datetime', + ]; + } +} +``` + +### app/Models/Project.php + +```php + */ + 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 */ + 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', + ]; + } +} +``` + +### database/factories/InvestorFactory.php + +```php + */ +class InvestorFactory extends Factory +{ + protected $model = Investor::class; + + public function definition(): array + { + return [ + 'source_system' => 'example', + 'external_id' => (string) Str::uuid(), + 'data_classification' => 'internal', + 'legal_name' => 'Investidor fictício de teste', + 'investor_type' => 'institutional', + 'status' => 'prospect', + ]; + } +} +``` + +### database/factories/ProjectFactory.php + +```php + */ +class ProjectFactory extends Factory +{ + protected $model = Project::class; + + public function definition(): array + { + return [ + 'source_system' => 'example', + 'external_id' => (string) Str::uuid(), + 'data_classification' => 'internal', + 'code' => 'EXAMPLE-'.Str::uuid(), + 'name' => 'Projecto fictício de teste', + 'status' => 'draft', + 'budget_amount' => '1000.00', + 'budget_currency' => 'MZN', + ]; + } +} +``` + +### database/seeders/DatabaseSeeder.php + +```php +environment(['local', 'testing'])) { + return; + } + + $this->call(InvestMozReferenceSeeder::class); + + // User::factory(10)->create(); + + User::firstOrCreate( + ['email' => 'test@example.com'], + [ + 'name' => 'Test User', + 'password' => Hash::make(Str::random(48)), + ], + ); + } +} +``` + +### database/seeders/InvestMozReferenceSeeder.php + +```php +environment(['local', 'testing'])) { + throw new \RuntimeException('Reference fixtures are restricted to local/testing.'); + } + + Project::withTrashed()->updateOrCreate( + ['source_system' => 'example', 'external_id' => 'PROJECT-001'], + [ + 'code' => 'EXAMPLE-PROJECT-001', + 'name' => 'Projecto fictício para validação técnica', + 'status' => 'draft', + 'budget_amount' => '1000.00', + 'budget_currency' => 'MZN', + 'data_classification' => 'internal', + 'deleted_at' => null, + ], + ); + Investor::withTrashed()->updateOrCreate( + ['source_system' => 'example', 'external_id' => 'INVESTOR-001'], + [ + 'legal_name' => 'Entidade fictícia para validação técnica', + 'investor_type' => 'institutional', + 'status' => 'prospect', + 'data_classification' => 'internal', + 'deleted_at' => null, + ], + ); + } +} +``` + +## Comandos Artisan e testes + +Consultar runbook.md para pré-requisitos e protecção da base isolada. + +```sh +php artisan migrate:fresh --seed +php artisan migrate:rollback +php artisan test --configuration=phpunit.investmoz.xml +``` + +### tests/InvestMoz/SchemaLifecycleTest.php + +```php +fail('The database accepted an invalid operation: '.$sql); + } catch (QueryException $exception) { + $this->assertSame($sqlState, $exception->errorInfo[0]); + } finally { + DB::rollBack(); + } + } + + public function test_schema_constraints_events_and_rollback_in_isolated_postgis(): void + { + if (getenv('INVESTMOZ_SCHEMA_TESTS') !== '1') { + $this->markTestSkipped('Set INVESTMOZ_SCHEMA_TESTS=1 only for an isolated disposable *_test database.'); + } + $this->assertSame('testing', app()->environment()); + $this->assertSame('pgsql', DB::connection()->getDriverName()); + $this->assertStringEndsWith('_test', DB::connection()->getDatabaseName()); + $this->assertSame(DB::connection()->getDatabaseName(), DB::selectOne('SELECT current_database() AS name')->name); + $this->assertFileDoesNotExist(base_path('bootstrap/cache/config.php'), 'Clear cached configuration before the destructive test.'); + $this->artisan('migrate:fresh', ['--seed' => true, '--force' => true])->assertExitCode(0); + $manifest = json_decode(file_get_contents(base_path('docs/investmoz/schema.json')), true, 512, JSON_THROW_ON_ERROR); + + $legacy = json_decode(file_get_contents(base_path('docs/investmoz/legacy-schema.json')), true, 512, JSON_THROW_ON_ERROR); + foreach (array_merge($manifest['tables'], $legacy['tables']) as $table) { + $actual = DB::select( + "SELECT a.attname AS column_name, CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable, format_type(a.atttypid,a.atttypmod) AS pg_type, pg_get_expr(d.adbin,d.adrelid) AS default_expression FROM pg_attribute a LEFT JOIN pg_attrdef d ON d.adrelid=a.attrelid AND d.adnum=a.attnum JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relname=? AND a.attnum>0 AND NOT a.attisdropped ORDER BY a.attnum", + [$table['name']], + ); + $this->assertSame(array_column($table['columns'], 'name'), array_column($actual, 'column_name'), $table['name']); + foreach ($actual as $position => $column) { + $this->assertSame($table['columns'][$position]['nullable'] ? 'YES' : 'NO', $column->is_nullable); + $expectedType = str_replace(['timestamptz', 'char('], ['timestamp with time zone', 'character('], $table['columns'][$position]['type']); + $this->assertSame($expectedType, $column->pg_type, $table['name'].'.'.$column->column_name); + $expectedDefault = $table['columns'][$position]['default']; + $actualDefault = $column->default_expression === null ? null : str_replace(['::text', '::character varying'], '', $column->default_expression); + $this->assertSame($expectedDefault, $actualDefault, $table['name'].'.'.$column->column_name.' default'); + } + } + $expectedIndexes = array_merge(array_column($manifest['indexes'], 'name'), array_column($legacy['indexes'], 'name')); + foreach ($manifest['constraints'] as $constraint) { + if ($constraint['kind'] === 'PRIMARY KEY') { + $expectedIndexes[] = $constraint['name']; + } + } + foreach ($legacy['tables'] as $table) { + $expectedIndexes[] = $table['name'].'_pkey'; + } + sort($expectedIndexes); + $actualIndexes = DB::table('pg_indexes')->where('schemaname', 'public')->whereIn('tablename', array_column(array_merge($manifest['tables'], $legacy['tables']), 'name'))->orderBy('indexname')->pluck('indexname')->all(); + $this->assertSame($expectedIndexes, $actualIndexes); + foreach ($manifest['indexes'] as $index) { + $row = DB::selectOne("SELECT indexdef FROM pg_indexes WHERE schemaname='public' AND indexname=?", [$index['name']]); + $this->assertNotNull($row, $index['name']); + $this->assertStringContainsString('USING '.$index['method'], $row->indexdef); + } + foreach ($manifest['constraints'] as $constraint) { + $this->assertNotNull(DB::selectOne( + "SELECT oid FROM pg_constraint WHERE connamespace='public'::regnamespace AND conname=?", + [$constraint['name']], + ), $constraint['name']); + } + $actions = ['CASCADE' => 'c', 'RESTRICT' => 'r', 'SET NULL' => 'n', 'NO ACTION' => 'a']; + foreach (array_merge($manifest['relationships'], $legacy['relationships']) as $relationship) { + $actualFk = DB::selectOne("SELECT confdeltype, confupdtype FROM pg_constraint WHERE connamespace='public'::regnamespace AND conname=?", [$relationship['name']]); + $this->assertNotNull($actualFk, $relationship['name']); + $this->assertSame($actions[$relationship['on_delete']], $actualFk->confdeltype); + $this->assertSame($actions[$relationship['on_update']], $actualFk->confupdtype); + } + foreach ($manifest['triggers'] as $trigger) { + $this->assertNotNull(DB::selectOne('SELECT oid FROM pg_trigger WHERE tgname=? AND NOT tgisinternal', [$trigger['name']])); + } + + $project = DB::selectOne("INSERT INTO projects(code,name,status,source_system,external_id,budget_amount,budget_currency) VALUES ('TEST-P','Fictício','draft','test','P-1',123456789012345678.90,'MZN') RETURNING id,budget_amount"); + $this->assertSame('123456789012345678.90', $project->budget_amount); + $this->rejected("INSERT INTO projects(code,name,status) VALUES ('TEST-P','Duplicado','draft')", [], '23505'); + $this->rejected("INSERT INTO projects(code,name,status) VALUES ('BAD-S','Fictício','unknown')", [], '23514'); + $this->rejected("INSERT INTO projects(code,name,status,budget_amount,budget_currency) VALUES ('BAD-M','Fictício','draft',-1,'MZN')", [], '23514'); + $this->rejected("INSERT INTO projects(code,name,status,budget_amount) VALUES ('BAD-C','Fictício','draft',1)", [], '23514'); + DB::update('UPDATE projects SET deleted_at=now() WHERE id=?', [$project->id]); + DB::insert("INSERT INTO projects(code,name,status) VALUES ('TEST-P','Código reutilizado','draft')"); + $this->rejected("INSERT INTO projects(code,name,status,source_system,external_id) VALUES ('NEW-CODE','Fictício','draft','test','P-1')", [], '23505'); + + $location = DB::selectOne("INSERT INTO geo_locations(name,location_type,code) VALUES ('Local fictício','site','TEST-GEO') RETURNING id"); + DB::insert("INSERT INTO geo_geometries(geo_location_id,geometry_type,geom) VALUES (?,'point',ST_GeomFromText('POINT(32 -25)',4326))", [$location->id]); + $this->rejected("INSERT INTO geo_geometries(geo_location_id,geometry_type,geom) VALUES (?,'line',ST_GeomFromText('POINT(32 -25)',4326))", [$location->id], '23514'); + $this->rejected("INSERT INTO geo_geometries(geo_location_id,geometry_type,geom) VALUES (?,'polygon',ST_GeomFromText('POLYGON((0 0,1 1,1 0,0 1,0 0))',4326))", [$location->id], '23514'); + $this->rejected("INSERT INTO geo_geometries(geo_location_id,geometry_type,geom) VALUES (?,'point',ST_GeomFromText('POINT EMPTY',4326))", [$location->id], '23514'); + $this->rejected("INSERT INTO geo_geometries(geo_location_id,geometry_type,geom) VALUES (?,'point',ST_GeomFromText('POINT(181 0)',4326))", [$location->id], '23514'); + $this->rejected("INSERT INTO geo_geometries(geo_location_id,geometry_type,geom) VALUES (?,'point',ST_GeomFromText('POINT(1 1)',3857))", [$location->id], '22023'); + $this->rejected("INSERT INTO project_locations(project_id,geo_location_id,role) VALUES (gen_random_uuid(),?,'primary')", [$location->id], '23503'); + + $i1 = Investor::factory()->create(); + $i2 = Investor::factory()->create(); + $contact = DB::selectOne("INSERT INTO contacts(full_name) VALUES ('Contacto fictício') RETURNING id"); + DB::insert('INSERT INTO investor_contacts(investor_id,contact_id) VALUES (?,?),(?,?)', [$i1->id, $contact->id, $i2->id, $contact->id]); + $this->rejected('INSERT INTO investor_contacts(investor_id,contact_id) VALUES (?,?)', [$i1->id, $contact->id], '23505'); + $this->rejected('INSERT INTO project_documents(project_id,document_id) VALUES (?,gen_random_uuid())', [$project->id], '23503'); + + $audit = DB::selectOne("INSERT INTO audit_events(entity_type,action) VALUES ('test','created') RETURNING id"); + $this->rejected("UPDATE audit_events SET action='tampered' WHERE id=?", [$audit->id], '42501'); + $this->rejected('DELETE FROM audit_events WHERE id=?', [$audit->id], '42501'); + $this->rejected('TRUNCATE audit_events', [], '42501'); + $event = DB::selectOne("INSERT INTO outbox_events(aggregate_type,aggregate_id,event_type,payload,status) VALUES ('test',?,'test.created','{}','pending') RETURNING id", [$project->id]); + DB::update("UPDATE outbox_events SET status='published',published_at=now() WHERE id=?", [$event->id]); + $this->rejected("UPDATE outbox_events SET payload='{\"changed\":true}' WHERE id=?", [$event->id], '42501'); + $this->rejected('DELETE FROM outbox_events WHERE id=?', [$event->id], '42501'); + $this->rejected('TRUNCATE outbox_events', [], '42501'); + DB::insert("INSERT INTO inbox_events(message_id,event_type,payload,status) VALUES ('TEST-MESSAGE','test','{}','received')"); + $this->rejected("INSERT INTO inbox_events(message_id,event_type,payload,status) VALUES ('TEST-MESSAGE','test','{}','received')", [], '23505'); + + DB::statement("SET TIME ZONE 'Africa/Maputo'"); + try { + DB::update("UPDATE projects SET name='Actualizado' WHERE id=?", [$project->id]); + $age = DB::selectOne('SELECT abs(extract(epoch FROM (CURRENT_TIMESTAMP-updated_at))) AS age FROM projects WHERE id=?', [$project->id]); + $this->assertLessThan(5.0, (float) $age->age); + } finally { + DB::statement("SET TIME ZONE 'UTC'"); + } + // Index eligibility, not a latency/SLO claim on tiny fixtures. + DB::statement('SET enable_seqscan = off'); + try { + $plan = json_encode(DB::select('EXPLAIN (FORMAT JSON) SELECT id FROM geo_geometries WHERE geom && ST_MakeEnvelope(31,-26,33,-24,4326)')); + $this->assertStringContainsString('imz_geo_geometries_geom_idx', $plan); + } finally { + DB::statement('RESET enable_seqscan'); + } + $this->artisan('migrate')->assertExitCode(0); // Ledger idempotency. + $this->artisan('db:seed', ['--class' => 'InvestMozReferenceSeeder'])->assertExitCode(0); + $this->assertSame(1, DB::table('projects')->where('source_system', 'example')->where('external_id', 'PROJECT-001')->count()); + $this->artisan('migrate:rollback', ['--step' => 15, '--force' => true])->assertExitCode(0); + foreach ($manifest['tables'] as $table) { + $this->assertFalse(Schema::hasTable($table['name'])); + } + $this->assertTrue(Schema::hasTable('users')); + $this->assertTrue(Schema::hasTable('opportunities')); + $this->assertCount(2, DB::select("SELECT extname FROM pg_extension WHERE extname IN ('postgis','pgcrypto')")); + $this->artisan('migrate', ['--seed' => true, '--force' => true])->assertExitCode(0); + // This is a separate upgrade batch: ordinary rollback must now revert it. + $this->artisan('migrate:rollback', ['--force' => true])->assertExitCode(0); + $this->assertFalse(Schema::hasTable('projects')); + $this->assertTrue(Schema::hasTable('users')); + $this->artisan('migrate', ['--force' => true])->assertExitCode(0); + } +} +``` + +## Consultas de verificação + +```sql +-- Read-only verification after migrate, in isolated PostgreSQL. +SELECT version(), PostGIS_Full_Version(); +SELECT extname,extversion FROM pg_extension WHERE extname IN ('postgis','pgcrypto'); +SELECT table_name,column_name,data_type,udt_name,is_nullable,column_default, + character_maximum_length,numeric_precision,numeric_scale +FROM information_schema.columns WHERE table_schema='public' ORDER BY table_name,ordinal_position; +SELECT conrelid::regclass,conname,contype,pg_get_constraintdef(oid) +FROM pg_constraint WHERE connamespace='public'::regnamespace ORDER BY 1,2; +SELECT tablename,indexname,indexdef FROM pg_indexes WHERE schemaname='public' ORDER BY 1,2; +SELECT tgrelid::regclass,tgname,pg_get_triggerdef(oid) +FROM pg_trigger WHERE NOT tgisinternal AND tgname LIKE 'imz_%' ORDER BY 1,2; +SELECT * FROM pg_policies WHERE schemaname='public'; +SELECT id FROM geo_geometries WHERE ST_SRID(geom)<>4326 OR ST_NDims(geom)<>2 OR ST_IsEmpty(geom) OR NOT ST_IsValid(geom); +SELECT p.id FROM project_locations p LEFT JOIN projects t ON t.id=p.project_id WHERE t.id IS NULL; +SELECT c.investor_id,c.contact_id FROM investor_contacts c LEFT JOIN investors i ON i.id=c.investor_id WHERE i.id IS NULL; +SELECT source_system,external_id,count(*) FROM projects +WHERE external_id IS NOT NULL GROUP BY source_system,external_id HAVING count(*)>1; +SELECT id FROM projects WHERE budget_amount<0 OR ((budget_amount IS NULL)<>(budget_currency IS NULL)); +EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) +SELECT id FROM outbox_events WHERE status='pending' AND available_at<=CURRENT_TIMESTAMP ORDER BY available_at LIMIT 100; +EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) +SELECT id FROM geo_geometries WHERE ST_Intersects(geom,ST_MakeEnvelope(32,-26,33,-25,4326)); +``` + +## Plano de rollback + +# Execução, migração de dados e rollback + +## Pré-requisitos +PHP 8.4.1+ para o composer.lock instalado; composer.json declara ^8.3. Runtime compatível com composer.lock, pdo_pgsql, dependências Composer, PostgreSQL/PostGIS. Referência: PostgreSQL 16 com PostGIS 3.4 ou superior compatível; confirmar no servidor. +Usar base isolada terminada em _test. Configurar DB_CONNECTION=pgsql, DB_DATABASE=investmoz_test, DB_HOST, DB_PORT, DB_USERNAME e DB_PASSWORD no ambiente seguro; nunca versionar credenciais. +O administrador pode provisionar postgis/pgcrypto antes; caso contrário a conta de migrations necessita dessa capacidade. + +## Comandos Artisan +Os ficheiros já estão criados. Para futuras alterações: + php artisan make:migration descricao_da_alteracao + php artisan migrate --pretend + php artisan migrate --seed + php artisan migrate:status + +Apenas numa base de desenvolvimento descartável: + php artisan migrate:fresh --seed + php artisan migrate:rollback + php artisan migrate --seed + +Idempotência é assegurada pelo ledger migrations; não se usa IF NOT EXISTS para esconder deriva de tabelas. Extensões partilhadas usam IF NOT EXISTS. Funções próprias investmoz_* usam CREATE OR REPLACE porque migrate:fresh remove tabelas, mas conserva funções PostgreSQL. +Cada migration usa a transacção gerida pelo Laravel, sem BEGIN/COMMIT embutidos. Uma falha deixa o ficheiro pendente, preservando os anteriores. + +## Testes + php artisan config:clear + php artisan test + php artisan test --configuration=phpunit.investmoz.xml + python tools/investmoz/validate.py + python tools/investmoz/generate.py --source "caminho/do/documento.docx" --check + +O ensaio dedicado exige INVESTMOZ_SCHEMA_TESTS=1 e base terminada em _test; executa migrate:fresh e elimina dados dessa base. +As novas migrations rejeitam SQLite explicitamente. A suite principal deve usar PostgreSQL. +Testes estáticos não equivalem a execução PostgreSQL/PostGIS. Desempenho usa EXPLAIN sem limiar inventado. + +## MySQL -> PostgreSQL +1. Inventariar versão, charset, collation, contagens, moedas, fusos e ligações da origem real. +2. Obter snapshot e backup com restauração verificada e manifesto SHA-256. +3. Criar destino vazio e executar migrations sem seeds de demonstração. +4. Preservar IDs bigint do legado; atribuir UUID às novas entidades com mapa persistente origem/ID/UUID. Resolver P-08 antes de mapear opportunities para projects/CRM. +5. Transportar decimais como strings, sem float. Normalizar datas com fuso aprovado; colocar zero dates e datas inválidas em quarentena. +6. Validar JSON e geometrias 2D SRID 4326; recusar correcções geográficas silenciosas. +7. Importar por dependências em lotes transaccionais; deduplicar por source_system/external_id e comparar version_no atomicamente. +8. Guardar checkpoint na transacção do lote; não colocar dados pessoais nos logs. +9. Reconciliar contagens, somas, hashes canónicos, duplicados, órfãos e geometrias; tolerâncias ainda pendentes. +10. Ajustar sequences bigint com setval após verificar MAX(id); guardar origem para reversão. +11. Ensaiar corte, deltas e restauro antes de transferir tráfego. Esta entrega não executa importação. + +## Rollback +down remove triggers, índices avançados, FKs/checks/PKs e tabelas em ordem inversa, depois funções próprias. Não remove extensões nem usa DROP CASCADE. +Reverter tabelas POPULADAS destrói os seus dados, mesmo sem CASCADE. Não é reversão sem perda. +Antes de rollback fora do ensaio: suspender escritores, exportar tabelas, verificar restauração separada e guardar ledger. Preferir migration compensatória. +Num upgrade, migrate:rollback reverte o lote novo. Após migrate:fresh, todas as migrations podem pertencer ao mesmo lote; rollback pode remover também o legado. +RESTRICT deve bloquear dependências externas; não contornar com CASCADE. Manter aplicação compatível com o esquema restaurado. +Rollback de esquema não reconverte automaticamente dados para MySQL. + +## Fontes técnicas +https://www.postgresql.org/docs/16/ddl-constraints.html +https://postgis.net/docs/ST_IsValid.html +https://postgis.net/docs/ST_IsEmpty.html + +## Checklist para cada ambiente +Consultar validation.md para as evidencias do ensaio local ja executado. Os itens seguintes devem ser repetidos no ambiente de destino. +- [x] Reconciliação autorizada e migrations históricas preservadas. +- [x] Catálogo, dicionário e SQL gerados da mesma representação. +- [x] UUID novos, FKs compatíveis, decimais, geometrias e índices explícitos. +- [x] Contactos N:N e documentos tipados. +- [x] Extensões preservadas no rollback. +- [x] Lacunas documentadas e fora do DDL. +- [ ] migrate:fresh --seed e rollback confirmados em PostgreSQL/PostGIS. +- [ ] Suite da aplicação executada em PostgreSQL. +- [ ] Matriz de permissões, importação real, reconciliação e restauro validados. +- [ ] Desempenho confirmado com volume e SLO aprovados. + + +## Matriz de rastreabilidade + +| Domínio | Tabela | Campo | Migration | Requisito/Fonte | Decisão | +| --- | --- | --- | --- | --- | --- | +| Aplicação existente | users | id | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | users | name | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | users | email | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | users | email_verified_at | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | users | password | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | users | remember_token | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | users | created_at | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | users | updated_at | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | users | two_factor_secret | database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | users | two_factor_recovery_codes | database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | users | two_factor_confirmed_at | database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | password_reset_tokens | email | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | password_reset_tokens | token | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | password_reset_tokens | created_at | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sessions | id | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sessions | user_id | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sessions | ip_address | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sessions | user_agent | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sessions | payload | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sessions | last_activity | database/migrations/0001_01_01_000000_create_users_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | cache | key | database/migrations/0001_01_01_000001_create_cache_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | cache | value | database/migrations/0001_01_01_000001_create_cache_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | cache | expiration | database/migrations/0001_01_01_000001_create_cache_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | cache_locks | key | database/migrations/0001_01_01_000001_create_cache_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | cache_locks | owner | database/migrations/0001_01_01_000001_create_cache_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | cache_locks | expiration | database/migrations/0001_01_01_000001_create_cache_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | jobs | id | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | jobs | queue | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | jobs | payload | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | jobs | attempts | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | jobs | reserved_at | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | jobs | available_at | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | jobs | created_at | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | job_batches | id | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | job_batches | name | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | job_batches | total_jobs | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | job_batches | pending_jobs | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | job_batches | failed_jobs | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | job_batches | failed_job_ids | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | job_batches | options | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | job_batches | cancelled_at | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | job_batches | created_at | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | job_batches | finished_at | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | failed_jobs | id | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | failed_jobs | uuid | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | failed_jobs | connection | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | failed_jobs | queue | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | failed_jobs | payload | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | failed_jobs | exception | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | failed_jobs | failed_at | database/migrations/0001_01_01_000002_create_jobs_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | passkeys | id | database/migrations/2024_01_01_000000_create_passkeys_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | passkeys | user_id | database/migrations/2024_01_01_000000_create_passkeys_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | passkeys | name | database/migrations/2024_01_01_000000_create_passkeys_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | passkeys | credential_id | database/migrations/2024_01_01_000000_create_passkeys_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | passkeys | credential | database/migrations/2024_01_01_000000_create_passkeys_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | passkeys | last_used_at | database/migrations/2024_01_01_000000_create_passkeys_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | passkeys | created_at | database/migrations/2024_01_01_000000_create_passkeys_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | passkeys | updated_at | database/migrations/2024_01_01_000000_create_passkeys_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sectors | id | database/migrations/2026_01_01_000001_create_sectors_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sectors | name | database/migrations/2026_01_01_000001_create_sectors_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sectors | slug | database/migrations/2026_01_01_000001_create_sectors_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sectors | short_description | database/migrations/2026_01_01_000001_create_sectors_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sectors | description | database/migrations/2026_01_01_000001_create_sectors_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sectors | icon | database/migrations/2026_01_01_000001_create_sectors_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sectors | sort_order | database/migrations/2026_01_01_000001_create_sectors_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sectors | is_active | database/migrations/2026_01_01_000001_create_sectors_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sectors | published_at | database/migrations/2026_01_01_000001_create_sectors_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sectors | created_at | database/migrations/2026_01_01_000001_create_sectors_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sectors | updated_at | database/migrations/2026_01_01_000001_create_sectors_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | sectors | deleted_at | database/migrations/2026_01_01_000001_create_sectors_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | id | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | sector_id | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | reference | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | title | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | slug | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | summary | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | description | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | province | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | district | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | promoter_name | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | promoter_type | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | maturity_level | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | maturity_label | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | estimated_investment_amount | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | estimated_investment_currency | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | financing_modality | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | expected_impact | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | status | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | is_featured | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | published_at | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | last_validated_at | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | created_at | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | updated_at | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | opportunities | deleted_at | database/migrations/2026_01_01_000002_create_opportunities_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | id | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | opportunity_id | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | reference | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | full_name | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | email | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | phone | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | investor_type | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | country | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | sector_interest | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | opportunity_reference | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | investment_range | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | message | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | privacy_consent | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | consented_at | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | status | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | source_page | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | ip_address | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | user_agent | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | metadata | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | reviewed_at | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | reviewed_by | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | created_at | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | updated_at | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Aplicação existente | interest_requests | deleted_at | database/migrations/2026_01_01_000003_create_interest_requests_table.php | Migration instalada | COMPAT-01 | +| Identidade | organizations | id | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | organizations | source_system | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | organizations | external_id | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | organizations | version_no | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | organizations | data_classification | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | organizations | correlation_id | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | organizations | created_at | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | organizations | updated_at | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | organizations | deleted_at | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | organizations | name | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | organizations | organization_type | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | memberships | id | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | memberships | source_system | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | memberships | external_id | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | memberships | version_no | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | memberships | data_classification | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | memberships | correlation_id | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | memberships | created_at | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | memberships | updated_at | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | memberships | deleted_at | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | memberships | user_id | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | memberships | organization_id | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | memberships | role | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | id | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | source_system | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | external_id | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | version_no | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | data_classification | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | correlation_id | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | created_at | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | updated_at | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | deleted_at | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | user_id | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | subject_ref | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | purpose | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | status | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | granted_at | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Identidade | consents | withdrawn_at | database/migrations/2026_09_24_001000_investmoz_identity.php | A.26; A.31.2 | RECON-01 | +| Conteúdos | documents | id | database/migrations/2026_09_24_002000_investmoz_content.php | A.26; A.31.2 | RECON-01 | +| Conteúdos | documents | source_system | database/migrations/2026_09_24_002000_investmoz_content.php | A.26; A.31.2 | RECON-01 | +| Conteúdos | documents | external_id | database/migrations/2026_09_24_002000_investmoz_content.php | A.26; A.31.2 | RECON-01 | +| Conteúdos | documents | version_no | database/migrations/2026_09_24_002000_investmoz_content.php | A.26; A.31.2 | RECON-01 | +| Conteúdos | documents | data_classification | database/migrations/2026_09_24_002000_investmoz_content.php | A.26; A.31.2 | RECON-01 | +| Conteúdos | documents | correlation_id | database/migrations/2026_09_24_002000_investmoz_content.php | A.26; A.31.2 | RECON-01 | +| Conteúdos | documents | created_at | database/migrations/2026_09_24_002000_investmoz_content.php | A.26; A.31.2 | RECON-01 | +| Conteúdos | documents | updated_at | database/migrations/2026_09_24_002000_investmoz_content.php | A.26; A.31.2 | RECON-01 | +| Conteúdos | documents | deleted_at | database/migrations/2026_09_24_002000_investmoz_content.php | A.26; A.31.2 | RECON-01 | +| Conteúdos | documents | file_name | database/migrations/2026_09_24_002000_investmoz_content.php | A.26; A.31.2 | RECON-01 | +| Conteúdos | documents | media_type | database/migrations/2026_09_24_002000_investmoz_content.php | A.26; A.31.2 | RECON-01 | +| Conteúdos | documents | storage_key | database/migrations/2026_09_24_002000_investmoz_content.php | A.26; A.31.2 | RECON-01 | +| Conteúdos | documents | sha256 | database/migrations/2026_09_24_002000_investmoz_content.php | A.26; A.31.2 | RECON-01 | +| Conteúdos | documents | status | database/migrations/2026_09_24_002000_investmoz_content.php | A.26; A.31.2 | RECON-01 | +| Geografia | geo_locations | id | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_locations | source_system | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_locations | external_id | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_locations | version_no | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_locations | data_classification | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_locations | correlation_id | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_locations | created_at | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_locations | updated_at | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_locations | deleted_at | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_locations | name | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_locations | location_type | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_locations | parent_id | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_locations | code | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_geometries | id | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_geometries | source_system | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_geometries | external_id | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_geometries | version_no | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_geometries | data_classification | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_geometries | correlation_id | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_geometries | created_at | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_geometries | updated_at | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_geometries | deleted_at | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_geometries | geo_location_id | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_geometries | geometry_type | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_geometries | geom | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Geografia | geo_geometries | properties | database/migrations/2026_09_24_003000_investmoz_geography.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | id | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | source_system | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | external_id | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | version_no | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | data_classification | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | correlation_id | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | created_at | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | updated_at | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | deleted_at | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | code | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | name | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | status | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | description | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | budget_amount | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | budget_currency | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | start_date | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | end_date | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | projects | metadata | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | project_locations | id | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | project_locations | source_system | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | project_locations | external_id | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | project_locations | version_no | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | project_locations | data_classification | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | project_locations | correlation_id | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | project_locations | created_at | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | project_locations | updated_at | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | project_locations | deleted_at | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | project_locations | project_id | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | project_locations | geo_location_id | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Projectos | project_locations | role | database/migrations/2026_09_24_004000_investmoz_projects.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | economic_clusters | id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | economic_clusters | source_system | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | economic_clusters | external_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | economic_clusters | version_no | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | economic_clusters | data_classification | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | economic_clusters | correlation_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | economic_clusters | created_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | economic_clusters | updated_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | economic_clusters | deleted_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | economic_clusters | code | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | economic_clusters | name | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | economic_clusters | sector | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | economic_clusters | status | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | economic_clusters | metadata | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chains | id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chains | source_system | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chains | external_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chains | version_no | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chains | data_classification | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chains | correlation_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chains | created_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chains | updated_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chains | deleted_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chains | cluster_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chains | code | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chains | name | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chains | stage | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chain_links | id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chain_links | source_system | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chain_links | external_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chain_links | version_no | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chain_links | data_classification | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chain_links | correlation_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chain_links | created_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chain_links | updated_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chain_links | deleted_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chain_links | from_value_chain_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chain_links | to_value_chain_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | value_chain_links | link_type | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | cluster_projects | id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | cluster_projects | source_system | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | cluster_projects | external_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | cluster_projects | version_no | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | cluster_projects | data_classification | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | cluster_projects | correlation_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | cluster_projects | created_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | cluster_projects | updated_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | cluster_projects | deleted_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | cluster_projects | cluster_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | cluster_projects | project_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | cluster_projects | role | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | anchor_project_links | id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | anchor_project_links | source_system | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | anchor_project_links | external_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | anchor_project_links | version_no | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | anchor_project_links | data_classification | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | anchor_project_links | correlation_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | anchor_project_links | created_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | anchor_project_links | updated_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | anchor_project_links | deleted_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | anchor_project_links | anchor_project_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | anchor_project_links | cluster_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | anchor_project_links | rationale | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | complementary_project_links | id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | complementary_project_links | source_system | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | complementary_project_links | external_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | complementary_project_links | version_no | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | complementary_project_links | data_classification | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | complementary_project_links | correlation_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | complementary_project_links | created_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | complementary_project_links | updated_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | complementary_project_links | deleted_at | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | complementary_project_links | anchor_project_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | complementary_project_links | complementary_project_id | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Clusters e cadeias | complementary_project_links | relation_type | database/migrations/2026_09_24_005000_investmoz_clusters.php | A.25; A.31.2 | RECON-01 | +| Corredores | development_corridors | id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | development_corridors | source_system | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | development_corridors | external_id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | development_corridors | version_no | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | development_corridors | data_classification | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | development_corridors | correlation_id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | development_corridors | created_at | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | development_corridors | updated_at | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | development_corridors | deleted_at | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | development_corridors | code | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | development_corridors | name | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | development_corridors | status | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | development_corridors | description | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_locations | id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_locations | source_system | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_locations | external_id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_locations | version_no | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_locations | data_classification | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_locations | correlation_id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_locations | created_at | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_locations | updated_at | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_locations | deleted_at | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_locations | corridor_id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_locations | geo_location_id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_locations | sequence_no | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_projects | id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_projects | source_system | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_projects | external_id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_projects | version_no | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_projects | data_classification | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_projects | correlation_id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_projects | created_at | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_projects | updated_at | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_projects | deleted_at | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_projects | corridor_id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_projects | project_id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_clusters | id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_clusters | source_system | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_clusters | external_id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_clusters | version_no | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_clusters | data_classification | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_clusters | correlation_id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_clusters | created_at | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_clusters | updated_at | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_clusters | deleted_at | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_clusters | corridor_id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| Corredores | corridor_clusters | cluster_id | database/migrations/2026_09_24_006000_investmoz_corridors.php | A.25; A.31.2 | RECON-01 | +| CRM | investors | id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investors | source_system | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investors | external_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investors | version_no | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investors | data_classification | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investors | correlation_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investors | created_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investors | updated_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investors | deleted_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investors | organization_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investors | legal_name | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investors | investor_type | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investors | status | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | contacts | id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | contacts | source_system | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | contacts | external_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | contacts | version_no | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | contacts | data_classification | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | contacts | correlation_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | contacts | created_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | contacts | updated_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | contacts | deleted_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | contacts | full_name | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | contacts | email | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | contacts | phone | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investor_contacts | investor_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investor_contacts | contact_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investor_contacts | is_primary | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investor_contacts | created_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | investor_contacts | updated_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | source_system | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | external_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | version_no | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | data_classification | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | correlation_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | created_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | updated_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | deleted_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | investor_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | owner_user_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | title | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | status | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | estimated_amount | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | leads | currency | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | source_system | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | external_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | version_no | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | data_classification | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | correlation_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | created_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | updated_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | deleted_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | lead_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | project_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | owner_user_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | stage | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | amount | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | currency | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | crm_opportunities | probability | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | source_system | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | external_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | version_no | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | data_classification | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | correlation_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | created_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | updated_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | deleted_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | investor_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | opportunity_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | actor_user_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | activity_type | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | occurred_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | activities | summary | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | tasks | id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | tasks | source_system | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | tasks | external_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | tasks | version_no | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | tasks | data_classification | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | tasks | correlation_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | tasks | created_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | tasks | updated_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | tasks | deleted_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | tasks | assignee_user_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | tasks | opportunity_id | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | tasks | title | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | tasks | status | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| CRM | tasks | due_at | database/migrations/2026_09_24_007000_investmoz_crm.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | investor_journeys | id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | investor_journeys | source_system | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | investor_journeys | external_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | investor_journeys | version_no | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | investor_journeys | data_classification | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | investor_journeys | correlation_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | investor_journeys | created_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | investor_journeys | updated_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | investor_journeys | deleted_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | investor_journeys | investor_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | investor_journeys | opportunity_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | investor_journeys | status | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | investor_journeys | started_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | journey_steps | id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | journey_steps | source_system | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | journey_steps | external_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | journey_steps | version_no | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | journey_steps | data_classification | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | journey_steps | correlation_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | journey_steps | created_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | journey_steps | updated_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | journey_steps | deleted_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | journey_steps | journey_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | journey_steps | step_code | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | journey_steps | sequence_no | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | journey_steps | status | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | submissions | id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | submissions | source_system | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | submissions | external_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | submissions | version_no | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | submissions | data_classification | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | submissions | correlation_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | submissions | created_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | submissions | updated_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | submissions | deleted_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | submissions | journey_step_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | submissions | submitted_by | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | submissions | status | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | submissions | payload | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | submissions | submitted_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | aftercare_plans | id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | aftercare_plans | source_system | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | aftercare_plans | external_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | aftercare_plans | version_no | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | aftercare_plans | data_classification | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | aftercare_plans | correlation_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | aftercare_plans | created_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | aftercare_plans | updated_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | aftercare_plans | deleted_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | aftercare_plans | investor_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | aftercare_plans | project_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | aftercare_plans | owner_user_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | aftercare_plans | status | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | aftercare_plans | review_date | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | constraints | id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | constraints | source_system | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | constraints | external_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | constraints | version_no | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | constraints | data_classification | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | constraints | correlation_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | constraints | created_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | constraints | updated_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | constraints | deleted_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | constraints | aftercare_plan_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | constraints | category | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | constraints | description | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | constraints | severity | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | constraints | status | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | escalations | id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | escalations | source_system | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | escalations | external_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | escalations | version_no | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | escalations | data_classification | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | escalations | correlation_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | escalations | created_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | escalations | updated_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | escalations | deleted_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | escalations | constraint_id | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | escalations | escalated_by | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | escalations | level | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | escalations | status | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Jornada e aftercare | escalations | escalated_at | database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php | A.26; A.31.2 | RECON-01 | +| Educação | skills | id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | skills | source_system | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | skills | external_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | skills | version_no | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | skills | data_classification | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | skills | correlation_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | skills | created_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | skills | updated_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | skills | deleted_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | skills | code | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | skills | name | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | occupations | id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | occupations | source_system | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | occupations | external_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | occupations | version_no | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | occupations | data_classification | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | occupations | correlation_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | occupations | created_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | occupations | updated_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | occupations | deleted_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | occupations | code | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | occupations | name | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_needs | id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_needs | source_system | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_needs | external_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_needs | version_no | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_needs | data_classification | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_needs | correlation_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_needs | created_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_needs | updated_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_needs | deleted_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_needs | project_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_needs | occupation_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_needs | skill_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_needs | quantity | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_needs | priority | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_providers | id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_providers | source_system | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_providers | external_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_providers | version_no | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_providers | data_classification | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_providers | correlation_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_providers | created_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_providers | updated_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_providers | deleted_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_providers | organization_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_providers | name | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | training_providers | status | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | courses | id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | courses | source_system | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | courses | external_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | courses | version_no | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | courses | data_classification | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | courses | correlation_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | courses | created_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | courses | updated_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | courses | deleted_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | courses | provider_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | courses | code | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | courses | title | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | courses | status | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | source_system | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | external_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | version_no | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | data_classification | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | correlation_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | created_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | updated_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | deleted_at | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | course_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | geo_location_id | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | start_date | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | end_date | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | capacity | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Educação | education_opportunities | status | database/migrations/2026_09_24_009000_investmoz_education.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | project_documents | project_id | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | project_documents | document_id | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | project_documents | created_at | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | investor_documents | investor_id | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | investor_documents | document_id | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | investor_documents | created_at | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | journey_documents | journey_id | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | journey_documents | document_id | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | journey_documents | created_at | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | aftercare_documents | aftercare_plan_id | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | aftercare_documents | document_id | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | aftercare_documents | created_at | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | education_documents | education_opportunity_id | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | education_documents | document_id | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Conteúdos — ligações tipadas | education_documents | created_at | database/migrations/2026_09_24_009500_investmoz_document_links.php | A.26; A.31.2 | RECON-01 | +| Governação e auditoria | audit_events | id | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | A.26; A.31.2 | RECON-01 | +| Governação e auditoria | audit_events | source_system | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | A.26; A.31.2 | RECON-01 | +| Governação e auditoria | audit_events | external_id | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | A.26; A.31.2 | RECON-01 | +| Governação e auditoria | audit_events | data_classification | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | A.26; A.31.2 | RECON-01 | +| Governação e auditoria | audit_events | correlation_id | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | A.26; A.31.2 | RECON-01 | +| Governação e auditoria | audit_events | created_at | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | A.26; A.31.2 | RECON-01 | +| Governação e auditoria | audit_events | actor_user_id | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | A.26; A.31.2 | RECON-01 | +| Governação e auditoria | audit_events | entity_type | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | A.26; A.31.2 | RECON-01 | +| Governação e auditoria | audit_events | entity_id | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | A.26; A.31.2 | RECON-01 | +| Governação e auditoria | audit_events | action | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | A.26; A.31.2 | RECON-01 | +| Governação e auditoria | audit_events | occurred_at | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | A.26; A.31.2 | RECON-01 | +| Governação e auditoria | audit_events | before_data | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | A.26; A.31.2 | RECON-01 | +| Governação e auditoria | audit_events | after_data | database/migrations/2026_09_24_010000_investmoz_governance_ai.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | source_system | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | external_id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | version_no | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | data_classification | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | correlation_id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | created_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | updated_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | aggregate_type | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | aggregate_id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | event_type | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | payload | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | status | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | available_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | outbox_events | published_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | inbox_events | id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | inbox_events | source_system | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | inbox_events | external_id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | inbox_events | version_no | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | inbox_events | data_classification | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | inbox_events | correlation_id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | inbox_events | created_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | inbox_events | updated_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | inbox_events | message_id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | inbox_events | event_type | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | inbox_events | payload | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | inbox_events | status | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | inbox_events | received_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | inbox_events | processed_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | source_system | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | external_id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | version_no | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | data_classification | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | correlation_id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | created_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | updated_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | deleted_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | integration_name | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | direction | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | status | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | started_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | ended_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | sync_runs | totals | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | source_system | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | external_id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | version_no | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | data_classification | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | correlation_id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | created_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | updated_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | deleted_at | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | sync_run_id | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | entity_type | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | external_id_ref | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | issue_type | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | severity | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | status | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | +| Integrações e eventos | reconciliation_issues | details | database/migrations/2026_09_24_011000_investmoz_integrations.php | A.26; A.31.2 | RECON-01 | + +## Checklist final de conformidade + +A checklist e as limitações de execução estão em runbook.md. As evidencias locais estao em validation.md. Os ensaios PostgreSQL não foram marcados como executados pelo gerador. diff --git a/docs/investmoz/architecture-data-security-qa-product-review.md b/docs/investmoz/architecture-data-security-qa-product-review.md new file mode 100644 index 00000000..50c613bd --- /dev/null +++ b/docs/investmoz/architecture-data-security-qa-product-review.md @@ -0,0 +1,84 @@ +# Revisão de Arquitectura, Dados, Segurança, QA e Produto + +Data: 26/09/2026. Âmbito: carteira territorial, importação/publicação e assistência interna de IA, incluindo integração com o percurso do investidor. Revisão estática do código e dos testes existentes; não constitui auditoria integral do portal ou teste de intrusão. Nesta revisão não foram alterados código aplicacional, dados ou aprovações. + +A decisão do proprietário de aprovar a carteira e permitir aprovação pelo próprio gestor foi respeitada. Os problemas abaixo não exigem regressar à regra de duas pessoas. + +## Actualização de implementação — 28/09/2026 + +**R1 corrigido na aplicação local.** As fichas têm `version_no`; guardar e publicar comparam a versão enviada com a linha bloqueada na transacção e recusam versões antigas com HTTP 409. Ambas as operações incrementam a versão, incluindo publicação pela importação documental. O editor renova os formulários após cada alteração e impede publicar enquanto houver alterações locais por guardar. A aprovação pelo próprio gestor autorizado mantém-se. + +O teste de regressão cobre gravação concorrente, publicação obsoleta, repetição da publicação, ausência de versão, preservação do conteúdo e ocultação dos metadados internos na página pública. Suite completa: 138 testes e 1.463 verificações; PHPStan, TypeScript, lint e build aprovados. Migration aplicada após backup/restauro verificado de 93 tabelas. As 29 fichas existentes mantiveram-se publicadas, na versão inicial 1. + +R2–R5 permanecem pendentes desta revisão. A versão editorial resolve concorrência; não constitui, por si só, o histórico completo de conteúdo solicitado em R3. Os achados abaixo conservam o diagnóstico original de 26/09/2026. + +## Achados por prioridade + +### R1 — Alta: publicação não está vinculada à versão vista pelo gestor + +Referências: `app/Http/Controllers/InvestmentPortfolioController.php:95`, `resources/js/pages/portfolio/show.tsx`, `app/Services/PortalAudit.php:16`. + +O pedido de publicação envia apenas a evidência de aprovação. O servidor bloqueia e publica a linha mais recente, sem comparar uma versão ou hash apresentado ao gestor. Se A abre uma ficha, B altera o conteúdo e A publica a partir da página antiga, fica publicado o conteúdo de B que A não viu. O bloqueio transaccional serializa escritas, mas não detecta uma página desactualizada. A gravação do formulário também permite substituir alterações concorrentes. + +Correcção: acrescentar versão monotónica; enviar a versão nos pedidos de guardar/publicar e comparar dentro da transacção. Responder 409 e pedir recarregamento quando divergir. Preservar a possibilidade de o próprio gestor aprovar. + +QA de aceitação: dois clientes abrem a versão N; um guarda N+1; guardar ou publicar com N deve falhar sem mudar conteúdo ou publicação. Publicar N+1 deve passar. + +### R2 — Alta: resultados históricos de qualidade e prazos não revalidam classificação + +Referências: `app/Http/Controllers/AiController.php:69`, `app/Services/Ai/AssistiveRules.php:66` e `:92`, `app/Services/Ai/EvidenceRepository.php:55`. + +As regras de qualidade e prazos filtram registos públicos/internos ao calcular o resultado. Contudo, esses resultados não têm `source_ids` documentais e a leitura posterior só verifica suspensão da capacidade; existem verificações adicionais para triagem e recomendações, mas não para qualidade/prazos. Depois de uma tarefa, lead ou geometria ser eliminado ou reclassificado como restrito, o resultado antigo continua a expor identificadores, relações, regras e, nas tarefas, prazos. A exposição é a gestores autorizados, não ao público, e não inclui necessariamente o texto integral do registo. + +Correcção: registar dependências estruturadas por tipo/ID e revalidar existência, classificação e autorização ao mostrar ou aceitar o resultado. Ocultar o resultado ou remover os itens inelegíveis, de forma explícita. + +QA de aceitação: gerar resultado, reclassificar/eliminar uma dependência e confirmar que leitura e aceitação não disponibilizam o item. Incluir leads, tarefas e geometrias. + +### R3 — Média: auditoria de publicação não permite reconstruir o conteúdo aprovado + +Referências: `app/Services/PortalAudit.php:20`, `app/Http/Controllers/InvestmentPortfolioController.php:84`, `app/Console/Commands/ImportInvestmentPortfolio.php:45`. + +A auditoria genérica conserva nomes de campos alterados e alguns estados, mas não a versão/hash do conteúdo publicado. Guardar a ficha apaga a evidência anterior da linha corrente. O comando de importação regista hashes do dataset e da autorização, o que melhora essa operação específica, mas o fluxo de edição/publicação manual não oferece equivalência. Consequentemente, o histórico permite saber quem publicou, mas não reconstruir com precisão o texto e a decisão daquela publicação após alterações. + +Correcção: conservar revisões imutáveis da ficha e da decisão em armazenamento com acesso restrito; referenciar revisão e hash no evento de auditoria, mantendo texto livre fora do log genérico. + +QA de aceitação: publicar, editar e republicar; recuperar separadamente as duas versões e respectivas decisões sem expor evidência no endpoint público. + +### R4 — Média: cadastro e ficha pública podem divergir + +Referências: `app/Http/Controllers/InvestmentStructureController.php` (`save`), `app/Http/Controllers/InvestmentPortfolioController.php:84`, `database/seeders/InvestmentPortfolioSeeder.php`. + +O cadastro de projectos/clusters e a ficha documental são editáveis em percursos diferentes. Alterar nome/descrição no cadastro não actualiza nem sinaliza a ficha pública; editar a ficha não actualiza o cadastro. O seeder preserva ambas as edições. Assim, a gestão pode mostrar um nome ou descrição diferente do que o investidor consulta sem indicar que a publicação é uma versão anterior. + +Correcção: definir a ficha como revisão editorial explícita do cadastro, guardar a versão de origem e mostrar aviso de divergência com acção de preparar nova revisão. Evitar sincronização automática que publique alterações sem aprovação. + +QA de aceitação: alterar cadastro de uma ficha publicada e verificar aviso de divergência, preservação da versão pública e reconciliação aprovada. + +### R5 — Média: recomendações da IA não incluem a carteira publicada + +Referência: `app/Services/Ai/AssistiveRules.php:17`. + +A capacidade `recommend` consulta apenas `Opportunity::published()`. Os 29 dossiers territoriais estão em `investment_dossiers`, ligados a projectos, clusters e corredores. A sua publicação não os torna candidatos à recomendação. Para um utilizador que procura projectos âncora pela assistência, os resultados podem ficar vazios embora existam fichas públicas relevantes. + +Correcção: explicitar o âmbito da capacidade na interface e, para cobrir a carteira, acrescentar consulta com tipos e filtros próprios, autorização e links para os dossiers. Não duplicar projectos como oportunidades apenas para contornar a separação dos modelos. + +QA de aceitação: pesquisa por um projecto documental publicado deve devolvê-lo no modo que declarar cobertura da carteira; rascunhos e entidades eliminadas permanecem excluídos. + +## Avaliação transversal + +| Área | Evidência e conclusão | +| --- | --- | +| Arquitectura | Laravel centraliza autorização e persistência; serviços de IA separados de controllers. R1 e R4 mostram necessidade de versões explícitas entre edição, cadastro e publicação. | +| Dados | Importação transaccional/idempotente, códigos documentais e relações preservados. Valores indicativos continuam em texto. R3 impede reconstituição completa do histórico editorial. | +| Segurança | Rotas de gestão exigem autenticação, email verificado e `manage-portal`; resultados IA encriptados e fontes documentais revalidadas. R2 deixa uma lacuna equivalente nas dependências estruturadas. | +| QA | Execução anterior registada: 119 testes / 1.107 verificações, PHPStan, Pint, TypeScript e build aprovados. Não repetidos nesta revisão. Os cenários de concorrência e reclassificação acima precisam de cobertura; testes verdes não os demonstram. | +| Produto | Consulta territorial, relações, painel e manifestação de interesse estão disponíveis. A assistência continua lexical/determinística e a integração externa SuiteCRM não foi executada. R5 limita a descoberta da carteira via IA. | + +## Ordem de tratamento + +1. R1: versões e conflito de edição/publicação. +2. R2: revalidação das dependências estruturadas de IA. +3. R3 e R4: histórico editorial e reconciliação cadastro/ficha. +4. R5: cobertura e clareza do âmbito da recomendação. + +Não foi identificado um achado crítico no código examinado. Os dois achados altos devem ser corrigidos antes de ampliar a utilização concorrente ou tratar dados sujeitos a reclassificação. Esta conclusão técnica não revoga a aprovação já concedida pelo proprietário. diff --git a/docs/investmoz/associations.csv b/docs/investmoz/associations.csv new file mode 100644 index 00000000..3908bc71 --- /dev/null +++ b/docs/investmoz/associations.csv @@ -0,0 +1,16 @@ +Tabela,PK,Chaves compostas únicas,Ligações +memberships,id,"user_id,organization_id; source_system,external_id",user_id -> users; organization_id -> organizations +project_locations,id,"project_id,geo_location_id,role; source_system,external_id",project_id -> projects; geo_location_id -> geo_locations +value_chain_links,id,"from_value_chain_id,to_value_chain_id,link_type; source_system,external_id",from_value_chain_id -> value_chains; to_value_chain_id -> value_chains +cluster_projects,id,"cluster_id,project_id; source_system,external_id",cluster_id -> economic_clusters; project_id -> projects +anchor_project_links,id,"anchor_project_id,cluster_id; source_system,external_id",anchor_project_id -> projects; cluster_id -> economic_clusters +complementary_project_links,id,"anchor_project_id,complementary_project_id,relation_type; source_system,external_id",anchor_project_id -> projects; complementary_project_id -> projects +corridor_locations,id,"corridor_id,geo_location_id; corridor_id,sequence_no; source_system,external_id",corridor_id -> development_corridors; geo_location_id -> geo_locations +corridor_projects,id,"corridor_id,project_id; source_system,external_id",corridor_id -> development_corridors; project_id -> projects +corridor_clusters,id,"corridor_id,cluster_id; source_system,external_id",corridor_id -> development_corridors; cluster_id -> economic_clusters +investor_contacts,"investor_id,contact_id",,investor_id -> investors; contact_id -> contacts +project_documents,"project_id,document_id",,project_id -> projects; document_id -> documents +investor_documents,"investor_id,document_id",,investor_id -> investors; document_id -> documents +journey_documents,"journey_id,document_id",,journey_id -> investor_journeys; document_id -> documents +aftercare_documents,"aftercare_plan_id,document_id",,aftercare_plan_id -> aftercare_plans; document_id -> documents +education_documents,"education_opportunity_id,document_id",,education_opportunity_id -> education_opportunities; document_id -> documents diff --git a/docs/investmoz/checksums.sha256 b/docs/investmoz/checksums.sha256 new file mode 100644 index 00000000..2ac0ecb8 --- /dev/null +++ b/docs/investmoz/checksums.sha256 @@ -0,0 +1,48 @@ +aba61fa85e074db4aab1973c91a2a476ce88f43d91f1c1ef8c471f4c8d9d3fc4 app/Models/Investor.php +d5831329ebc47ce8e367159b76817a612bf57ab07135aefad80f836aa362f0ac app/Models/Project.php +38db8127c708d1dcd41e9ffbce27593166156d050a082b60549e963e2499b585 database/factories/InvestorFactory.php +e8851a5ce4751e8d34d3f50d8ef3d103c76c88b8278fe0097750944aabf88080 database/factories/ProjectFactory.php +3daa494165853ba673f99012ed5651cfecfba153c676ae4b7099b9e6596ade38 database/migrations/2026_09_24_000100_investmoz_extensions_functions.php +f644b0369b17b2954b05f563a569d92533ae1bb06f89bf1cc8454d069b733b96 database/migrations/2026_09_24_001000_investmoz_identity.php +9f3006caa7092eb3651033d7756982b176a54c79ac47a9cf9a67c306e369a81c database/migrations/2026_09_24_002000_investmoz_content.php +405a75432fb9b90b4472c697a9239fc8d59907da8defbb985cf81cd3da958ee6 database/migrations/2026_09_24_003000_investmoz_geography.php +7b7f652cf323299c346133e7fda6564722eab60e8bba193387f8fa2af8bf2d35 database/migrations/2026_09_24_004000_investmoz_projects.php +93dc9bff75f759fc209f00b1bbb2d6727fbf4d3014fa368db394254789ad7df1 database/migrations/2026_09_24_005000_investmoz_clusters.php +8061e16466dfd505785bdd233851216cd01a62397d144da2f9c996a15b5481cf database/migrations/2026_09_24_006000_investmoz_corridors.php +ef49c4e0b15f1a0e2eaccc6c60b578e88a616aeb42e3a420865bb29aa83009a8 database/migrations/2026_09_24_007000_investmoz_crm.php +f64af9031e071ff30b94df64c13f507800928d3bb54e5d7d0bd6a8f2c495cd51 database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +fa6245b9a0a07dadd3512099450230919cfaa134c98b81c6362fb76abc9a3ea0 database/migrations/2026_09_24_009000_investmoz_education.php +68601d5dec9410609ae34050b137b46af3d8e09e3510a331aa824fc58d01ed9f database/migrations/2026_09_24_009500_investmoz_document_links.php +c66ce3cf351a97f164c325c87941260a448b2b301ed037d251e0562143e48f85 database/migrations/2026_09_24_010000_investmoz_governance_ai.php +3bab367d76ae5d3a9ef5836a95ddc41d01b35d24d119c7f1380b9153f4055b38 database/migrations/2026_09_24_011000_investmoz_integrations.php +ff069e7a2cd518b104671bb2879444aba1df504fce6664cd61cce80038901eca database/migrations/2026_09_24_012000_investmoz_indexes.php +a41f8563aada3f3dba8882e67c3647fd1e9edc88f11b382af59399d380f6b83e database/migrations/2026_09_24_013000_investmoz_triggers.php +5a5aedf020f281a6749e62f90e5fe6235f6b99236641bffff9049e6080454583 database/seeders/DatabaseSeeder.php +b8cefc04fe38b8f1d8c40fc527e9671a2fc34a80557165f1675a03b1ad45ff93 database/seeders/InvestMozReferenceSeeder.php +0c999f2754b58f56cbfc3f96bab7db641489bafe73a5901c62e8bcfde34cd0e0 docs/investmoz/README.md +488ed8672518b42619edfabb9ca7264e6ff330922bb1b824f0ee4a53dbf690ba docs/investmoz/architecture-and-migrations.md +aef72b1a2f40576feb1f3cd9df382cd82729ef2569505b297e658d560ae96730 docs/investmoz/associations.csv +61b10edeb087f6a021d3c1d54f45d7f1208ec92dfeb365086a18b10d3a8f77f5 docs/investmoz/constraints.csv +94ccf2d0a5361309634356a3f53dd79c23d9abc5b65928a0b3aaca6228069acf docs/investmoz/data-dictionary.csv +6377ed87016a505cf37bf9a69205321ea6c8cd9d6a32214f12225c5850b133b7 docs/investmoz/decisions.md +7373bf93e95db348b63192997d78ba1ce8414c95d6011c58312806022fc6e974 docs/investmoz/erd.mmd +142728247045678e5f026cdebfed7e4cd69ebc637680b91380f4d3e0f7349dd4 docs/investmoz/indexes.csv +4c6982432c7451d144ae1b03a8291e7796c977f698c8b809b57824aacf5d3629 docs/investmoz/legacy-schema.json +9d7613c948de2b1ba6f70b97472ac9f06ef445fb4d82ef1eaf3ca67dab239160 docs/investmoz/objects.json +e236a15a8bd8b55ae2c7c74a7e66185066ac24708344f5836ebea88545a544e3 docs/investmoz/relationships.csv +393bc08a00e6054d1881a256663ca25a487acffc16405b35441d5ac42da3a7e8 docs/investmoz/runbook.md +8881545beb64f48c7c1afe97d24744054bccfe9f698861d809c8e714a3bb29c4 docs/investmoz/schema.json +53783524c4eb928cd1eaa5c575a6f45fca4f1dee8b3aeb0be6d874807e177748 docs/investmoz/source-reconciliation.json +45ef0a893111afd342963d8240ca17fb276a090e92efd1ad2ab41d6a48c8e9d1 docs/investmoz/table-catalog.csv +4ad83685122a63d90c77eb2c1be2858861b61bdde4c2300012734482af77ef8f docs/investmoz/traceability.csv +d15685b13971cb526cdcef65c8511e79f9de7da28074450dea1bb5654bc69596 docs/investmoz/verification.sql +0444d3d575846a772bca1c1e791f78bfc351e5d84200238bb3251c11f366c2e9 phpunit.investmoz.xml +69c7c7391339eacad36e73f656232b2a3492f6826915d4b3c938fc0bff4585dc phpunit.xml +ab3c25168dd27bae1a6f03f0dde77012d60be44fa799d96487a63c6a3a735fdd tests/InvestMoz/SchemaLifecycleTest.php +46e9bd4efd1748ac5316f444cc7950cc0f930740a212ecd15b5ba8043836541a tools/investmoz/validate.py +e04d308a047e78fa66baba0539d8330f6f6be06bdc95ce4cba9179b4f9a0606e tools/investmoz/generate.py +aba61fa85e074db4aab1973c91a2a476ce88f43d91f1c1ef8c471f4c8d9d3fc4 tools/investmoz/templates/Investor.php.stub +38db8127c708d1dcd41e9ffbce27593166156d050a082b60549e963e2499b585 tools/investmoz/templates/InvestorFactory.php.stub +d5831329ebc47ce8e367159b76817a612bf57ab07135aefad80f836aa362f0ac tools/investmoz/templates/Project.php.stub +e8851a5ce4751e8d34d3f50d8ef3d103c76c88b8278fe0097750944aabf88080 tools/investmoz/templates/ProjectFactory.php.stub +ab3c25168dd27bae1a6f03f0dde77012d60be44fa799d96487a63c6a3a735fdd tools/investmoz/templates/SchemaLifecycleTest.php.stub diff --git a/docs/investmoz/constraints.csv b/docs/investmoz/constraints.csv new file mode 100644 index 00000000..f9064d20 --- /dev/null +++ b/docs/investmoz/constraints.csv @@ -0,0 +1,258 @@ +Tabela,Nome,Tipo,Expressão,Migration +organizations,imz_organizations_pk,PRIMARY KEY,id,database/migrations/2026_09_24_001000_investmoz_identity.php +organizations,imz_organizations_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_001000_investmoz_identity.php +organizations,imz_organizations_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_001000_investmoz_identity.php +organizations,imz_organizations_check_03,CHECK,"organization_type IN ('public','private','ngo','education')",database/migrations/2026_09_24_001000_investmoz_identity.php +memberships,imz_memberships_user_id_fk,FOREIGN KEY,user_id -> users.id,database/migrations/2026_09_24_001000_investmoz_identity.php +memberships,imz_memberships_organization_id_fk,FOREIGN KEY,organization_id -> organizations.id,database/migrations/2026_09_24_001000_investmoz_identity.php +memberships,imz_memberships_pk,PRIMARY KEY,id,database/migrations/2026_09_24_001000_investmoz_identity.php +memberships,imz_memberships_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_001000_investmoz_identity.php +memberships,imz_memberships_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_001000_investmoz_identity.php +memberships,imz_memberships_check_03,CHECK,"role IN ('admin','manager','officer','viewer')",database/migrations/2026_09_24_001000_investmoz_identity.php +consents,imz_consents_user_id_fk,FOREIGN KEY,user_id -> users.id,database/migrations/2026_09_24_001000_investmoz_identity.php +consents,imz_consents_pk,PRIMARY KEY,id,database/migrations/2026_09_24_001000_investmoz_identity.php +consents,imz_consents_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_001000_investmoz_identity.php +consents,imz_consents_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_001000_investmoz_identity.php +consents,imz_consents_check_03,CHECK,"status IN ('granted','withdrawn','expired')",database/migrations/2026_09_24_001000_investmoz_identity.php +documents,imz_documents_pk,PRIMARY KEY,id,database/migrations/2026_09_24_002000_investmoz_content.php +documents,imz_documents_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_002000_investmoz_content.php +documents,imz_documents_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_002000_investmoz_content.php +documents,imz_documents_check_03,CHECK,"status IN ('active','quarantined','deleted')",database/migrations/2026_09_24_002000_investmoz_content.php +documents,imz_documents_check_04,CHECK,sha256 ~ '^[0-9a-f]{64}$',database/migrations/2026_09_24_002000_investmoz_content.php +geo_locations,imz_geo_locations_parent_id_fk,FOREIGN KEY,parent_id -> geo_locations.id,database/migrations/2026_09_24_003000_investmoz_geography.php +geo_locations,imz_geo_locations_pk,PRIMARY KEY,id,database/migrations/2026_09_24_003000_investmoz_geography.php +geo_locations,imz_geo_locations_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_003000_investmoz_geography.php +geo_locations,imz_geo_locations_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_003000_investmoz_geography.php +geo_locations,imz_geo_locations_check_03,CHECK,"location_type IN ('country','province','district','municipality','site')",database/migrations/2026_09_24_003000_investmoz_geography.php +geo_geometries,imz_geo_geometries_geo_location_id_fk,FOREIGN KEY,geo_location_id -> geo_locations.id,database/migrations/2026_09_24_003000_investmoz_geography.php +geo_geometries,imz_geo_geometries_pk,PRIMARY KEY,id,database/migrations/2026_09_24_003000_investmoz_geography.php +geo_geometries,imz_geo_geometries_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_003000_investmoz_geography.php +geo_geometries,imz_geo_geometries_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_003000_investmoz_geography.php +geo_geometries,imz_geo_geometries_check_03,CHECK,"geometry_type IN ('point','line','polygon','multipolygon')",database/migrations/2026_09_24_003000_investmoz_geography.php +geo_geometries,imz_geo_geometries_check_04,CHECK,ST_SRID(geom) = 4326,database/migrations/2026_09_24_003000_investmoz_geography.php +geo_geometries,imz_geo_geometries_check_05,CHECK,ST_NDims(geom) = 2,database/migrations/2026_09_24_003000_investmoz_geography.php +geo_geometries,imz_geo_geometries_check_06,CHECK,NOT ST_IsEmpty(geom),database/migrations/2026_09_24_003000_investmoz_geography.php +geo_geometries,imz_geo_geometries_check_07,CHECK,ST_IsValid(geom),database/migrations/2026_09_24_003000_investmoz_geography.php +geo_geometries,imz_geo_geometries_check_08,CHECK,(geometry_type = 'point' AND GeometryType(geom) = 'POINT') OR (geometry_type = 'line' AND GeometryType(geom) = 'LINESTRING') OR (geometry_type = 'polygon' AND GeometryType(geom) = 'POLYGON') OR (geometry_type = 'multipolygon' AND GeometryType(geom) = 'MULTIPOLYGON'),database/migrations/2026_09_24_003000_investmoz_geography.php +geo_geometries,imz_geo_geometries_check_09,CHECK,ST_XMin(Box3D(geom)) >= -180 AND ST_XMax(Box3D(geom)) <= 180 AND ST_YMin(Box3D(geom)) >= -90 AND ST_YMax(Box3D(geom)) <= 90,database/migrations/2026_09_24_003000_investmoz_geography.php +projects,imz_projects_pk,PRIMARY KEY,id,database/migrations/2026_09_24_004000_investmoz_projects.php +projects,imz_projects_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_004000_investmoz_projects.php +projects,imz_projects_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_004000_investmoz_projects.php +projects,imz_projects_check_03,CHECK,"status IN ('draft','planned','active','suspended','completed','cancelled')",database/migrations/2026_09_24_004000_investmoz_projects.php +projects,imz_projects_check_04,CHECK,budget_amount IS NULL OR budget_amount >= 0,database/migrations/2026_09_24_004000_investmoz_projects.php +projects,imz_projects_check_05,CHECK,end_date IS NULL OR start_date IS NULL OR end_date >= start_date,database/migrations/2026_09_24_004000_investmoz_projects.php +projects,imz_projects_check_06,CHECK,(budget_amount IS NULL) = (budget_currency IS NULL),database/migrations/2026_09_24_004000_investmoz_projects.php +projects,imz_projects_check_07,CHECK,budget_currency IS NULL OR budget_currency ~ '^[A-Z]{3}$',database/migrations/2026_09_24_004000_investmoz_projects.php +project_locations,imz_project_locations_project_id_fk,FOREIGN KEY,project_id -> projects.id,database/migrations/2026_09_24_004000_investmoz_projects.php +project_locations,imz_project_locations_geo_location_id_fk,FOREIGN KEY,geo_location_id -> geo_locations.id,database/migrations/2026_09_24_004000_investmoz_projects.php +project_locations,imz_project_locations_pk,PRIMARY KEY,id,database/migrations/2026_09_24_004000_investmoz_projects.php +project_locations,imz_project_locations_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_004000_investmoz_projects.php +project_locations,imz_project_locations_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_004000_investmoz_projects.php +project_locations,imz_project_locations_check_03,CHECK,"role IN ('primary','secondary','impact')",database/migrations/2026_09_24_004000_investmoz_projects.php +economic_clusters,imz_economic_clusters_pk,PRIMARY KEY,id,database/migrations/2026_09_24_005000_investmoz_clusters.php +economic_clusters,imz_economic_clusters_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_005000_investmoz_clusters.php +economic_clusters,imz_economic_clusters_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_005000_investmoz_clusters.php +economic_clusters,imz_economic_clusters_check_03,CHECK,"status IN ('planned','active','inactive')",database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chains,imz_value_chains_cluster_id_fk,FOREIGN KEY,cluster_id -> economic_clusters.id,database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chains,imz_value_chains_pk,PRIMARY KEY,id,database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chains,imz_value_chains_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chains,imz_value_chains_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chains,imz_value_chains_check_03,CHECK,"stage IN ('input','production','processing','logistics','market')",database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chain_links,imz_value_chain_links_from_value_chain_id_fk,FOREIGN KEY,from_value_chain_id -> value_chains.id,database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chain_links,imz_value_chain_links_to_value_chain_id_fk,FOREIGN KEY,to_value_chain_id -> value_chains.id,database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chain_links,imz_value_chain_links_pk,PRIMARY KEY,id,database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chain_links,imz_value_chain_links_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chain_links,imz_value_chain_links_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chain_links,imz_value_chain_links_check_03,CHECK,"link_type IN ('supplies','processes','distributes','supports')",database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chain_links,imz_value_chain_links_check_04,CHECK,from_value_chain_id <> to_value_chain_id,database/migrations/2026_09_24_005000_investmoz_clusters.php +cluster_projects,imz_cluster_projects_cluster_id_fk,FOREIGN KEY,cluster_id -> economic_clusters.id,database/migrations/2026_09_24_005000_investmoz_clusters.php +cluster_projects,imz_cluster_projects_project_id_fk,FOREIGN KEY,project_id -> projects.id,database/migrations/2026_09_24_005000_investmoz_clusters.php +cluster_projects,imz_cluster_projects_pk,PRIMARY KEY,id,database/migrations/2026_09_24_005000_investmoz_clusters.php +cluster_projects,imz_cluster_projects_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_005000_investmoz_clusters.php +cluster_projects,imz_cluster_projects_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_005000_investmoz_clusters.php +cluster_projects,imz_cluster_projects_check_03,CHECK,"role IN ('anchor','complementary','supporting')",database/migrations/2026_09_24_005000_investmoz_clusters.php +anchor_project_links,imz_anchor_project_links_anchor_project_id_fk,FOREIGN KEY,anchor_project_id -> projects.id,database/migrations/2026_09_24_005000_investmoz_clusters.php +anchor_project_links,imz_anchor_project_links_cluster_id_fk,FOREIGN KEY,cluster_id -> economic_clusters.id,database/migrations/2026_09_24_005000_investmoz_clusters.php +anchor_project_links,imz_anchor_project_links_pk,PRIMARY KEY,id,database/migrations/2026_09_24_005000_investmoz_clusters.php +anchor_project_links,imz_anchor_project_links_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_005000_investmoz_clusters.php +anchor_project_links,imz_anchor_project_links_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_005000_investmoz_clusters.php +complementary_project_links,imz_complementary_project_links_anchor_project_id_fk,FOREIGN KEY,anchor_project_id -> projects.id,database/migrations/2026_09_24_005000_investmoz_clusters.php +complementary_project_links,imz_complementary_project_links_complementary_project_id_fk,FOREIGN KEY,complementary_project_id -> projects.id,database/migrations/2026_09_24_005000_investmoz_clusters.php +complementary_project_links,imz_complementary_project_links_pk,PRIMARY KEY,id,database/migrations/2026_09_24_005000_investmoz_clusters.php +complementary_project_links,imz_complementary_project_links_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_005000_investmoz_clusters.php +complementary_project_links,imz_complementary_project_links_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_005000_investmoz_clusters.php +complementary_project_links,imz_complementary_project_links_check_03,CHECK,"relation_type IN ('dependency','enabler','supplier','market')",database/migrations/2026_09_24_005000_investmoz_clusters.php +complementary_project_links,imz_complementary_project_links_check_04,CHECK,anchor_project_id<>complementary_project_id,database/migrations/2026_09_24_005000_investmoz_clusters.php +development_corridors,imz_development_corridors_pk,PRIMARY KEY,id,database/migrations/2026_09_24_006000_investmoz_corridors.php +development_corridors,imz_development_corridors_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_006000_investmoz_corridors.php +development_corridors,imz_development_corridors_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_006000_investmoz_corridors.php +development_corridors,imz_development_corridors_check_03,CHECK,"status IN ('proposed','active','closed')",database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_locations,imz_corridor_locations_corridor_id_fk,FOREIGN KEY,corridor_id -> development_corridors.id,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_locations,imz_corridor_locations_geo_location_id_fk,FOREIGN KEY,geo_location_id -> geo_locations.id,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_locations,imz_corridor_locations_pk,PRIMARY KEY,id,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_locations,imz_corridor_locations_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_locations,imz_corridor_locations_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_locations,imz_corridor_locations_check_03,CHECK,sequence_no>0,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_projects,imz_corridor_projects_corridor_id_fk,FOREIGN KEY,corridor_id -> development_corridors.id,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_projects,imz_corridor_projects_project_id_fk,FOREIGN KEY,project_id -> projects.id,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_projects,imz_corridor_projects_pk,PRIMARY KEY,id,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_projects,imz_corridor_projects_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_projects,imz_corridor_projects_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_clusters,imz_corridor_clusters_corridor_id_fk,FOREIGN KEY,corridor_id -> development_corridors.id,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_clusters,imz_corridor_clusters_cluster_id_fk,FOREIGN KEY,cluster_id -> economic_clusters.id,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_clusters,imz_corridor_clusters_pk,PRIMARY KEY,id,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_clusters,imz_corridor_clusters_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_clusters,imz_corridor_clusters_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_006000_investmoz_corridors.php +investors,imz_investors_organization_id_fk,FOREIGN KEY,organization_id -> organizations.id,database/migrations/2026_09_24_007000_investmoz_crm.php +investors,imz_investors_pk,PRIMARY KEY,id,database/migrations/2026_09_24_007000_investmoz_crm.php +investors,imz_investors_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_007000_investmoz_crm.php +investors,imz_investors_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_007000_investmoz_crm.php +investors,imz_investors_check_03,CHECK,"investor_type IN ('domestic','foreign','institutional','individual')",database/migrations/2026_09_24_007000_investmoz_crm.php +investors,imz_investors_check_04,CHECK,"status IN ('prospect','active','inactive')",database/migrations/2026_09_24_007000_investmoz_crm.php +contacts,imz_contacts_pk,PRIMARY KEY,id,database/migrations/2026_09_24_007000_investmoz_crm.php +contacts,imz_contacts_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_007000_investmoz_crm.php +contacts,imz_contacts_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_007000_investmoz_crm.php +investor_contacts,imz_investor_contacts_investor_id_fk,FOREIGN KEY,investor_id -> investors.id,database/migrations/2026_09_24_007000_investmoz_crm.php +investor_contacts,imz_investor_contacts_contact_id_fk,FOREIGN KEY,contact_id -> contacts.id,database/migrations/2026_09_24_007000_investmoz_crm.php +investor_contacts,imz_investor_contacts_pk,PRIMARY KEY,"investor_id, contact_id",database/migrations/2026_09_24_007000_investmoz_crm.php +leads,imz_leads_investor_id_fk,FOREIGN KEY,investor_id -> investors.id,database/migrations/2026_09_24_007000_investmoz_crm.php +leads,imz_leads_owner_user_id_fk,FOREIGN KEY,owner_user_id -> users.id,database/migrations/2026_09_24_007000_investmoz_crm.php +leads,imz_leads_pk,PRIMARY KEY,id,database/migrations/2026_09_24_007000_investmoz_crm.php +leads,imz_leads_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_007000_investmoz_crm.php +leads,imz_leads_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_007000_investmoz_crm.php +leads,imz_leads_check_03,CHECK,"status IN ('new','qualified','disqualified','converted')",database/migrations/2026_09_24_007000_investmoz_crm.php +leads,imz_leads_check_04,CHECK,(estimated_amount IS NULL)=(currency IS NULL),database/migrations/2026_09_24_007000_investmoz_crm.php +leads,imz_leads_check_05,CHECK,estimated_amount IS NULL OR estimated_amount >= 0,database/migrations/2026_09_24_007000_investmoz_crm.php +leads,imz_leads_check_06,CHECK,currency IS NULL OR currency ~ '^[A-Z]{3}$',database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,imz_crm_opportunities_lead_id_fk,FOREIGN KEY,lead_id -> leads.id,database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,imz_crm_opportunities_project_id_fk,FOREIGN KEY,project_id -> projects.id,database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,imz_crm_opportunities_owner_user_id_fk,FOREIGN KEY,owner_user_id -> users.id,database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,imz_crm_opportunities_pk,PRIMARY KEY,id,database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,imz_crm_opportunities_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,imz_crm_opportunities_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,imz_crm_opportunities_check_03,CHECK,"stage IN ('discovery','assessment','negotiation','approved','lost')",database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,imz_crm_opportunities_check_04,CHECK,probability BETWEEN 0 AND 100,database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,imz_crm_opportunities_check_05,CHECK,(amount IS NULL)=(currency IS NULL),database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,imz_crm_opportunities_check_06,CHECK,amount IS NULL OR amount >= 0,database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,imz_crm_opportunities_check_07,CHECK,currency IS NULL OR currency ~ '^[A-Z]{3}$',database/migrations/2026_09_24_007000_investmoz_crm.php +activities,imz_activities_investor_id_fk,FOREIGN KEY,investor_id -> investors.id,database/migrations/2026_09_24_007000_investmoz_crm.php +activities,imz_activities_opportunity_id_fk,FOREIGN KEY,opportunity_id -> crm_opportunities.id,database/migrations/2026_09_24_007000_investmoz_crm.php +activities,imz_activities_actor_user_id_fk,FOREIGN KEY,actor_user_id -> users.id,database/migrations/2026_09_24_007000_investmoz_crm.php +activities,imz_activities_pk,PRIMARY KEY,id,database/migrations/2026_09_24_007000_investmoz_crm.php +activities,imz_activities_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_007000_investmoz_crm.php +activities,imz_activities_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_007000_investmoz_crm.php +activities,imz_activities_check_03,CHECK,"activity_type IN ('call','meeting','email','note')",database/migrations/2026_09_24_007000_investmoz_crm.php +tasks,imz_tasks_assignee_user_id_fk,FOREIGN KEY,assignee_user_id -> users.id,database/migrations/2026_09_24_007000_investmoz_crm.php +tasks,imz_tasks_opportunity_id_fk,FOREIGN KEY,opportunity_id -> crm_opportunities.id,database/migrations/2026_09_24_007000_investmoz_crm.php +tasks,imz_tasks_pk,PRIMARY KEY,id,database/migrations/2026_09_24_007000_investmoz_crm.php +tasks,imz_tasks_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_007000_investmoz_crm.php +tasks,imz_tasks_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_007000_investmoz_crm.php +tasks,imz_tasks_check_03,CHECK,"status IN ('open','in_progress','done','cancelled')",database/migrations/2026_09_24_007000_investmoz_crm.php +investor_journeys,imz_investor_journeys_investor_id_fk,FOREIGN KEY,investor_id -> investors.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +investor_journeys,imz_investor_journeys_opportunity_id_fk,FOREIGN KEY,opportunity_id -> crm_opportunities.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +investor_journeys,imz_investor_journeys_pk,PRIMARY KEY,id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +investor_journeys,imz_investor_journeys_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +investor_journeys,imz_investor_journeys_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +investor_journeys,imz_investor_journeys_check_03,CHECK,"status IN ('started','active','completed','cancelled')",database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +journey_steps,imz_journey_steps_journey_id_fk,FOREIGN KEY,journey_id -> investor_journeys.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +journey_steps,imz_journey_steps_pk,PRIMARY KEY,id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +journey_steps,imz_journey_steps_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +journey_steps,imz_journey_steps_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +journey_steps,imz_journey_steps_check_03,CHECK,sequence_no>0,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +journey_steps,imz_journey_steps_check_04,CHECK,"status IN ('pending','active','completed','blocked')",database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +submissions,imz_submissions_journey_step_id_fk,FOREIGN KEY,journey_step_id -> journey_steps.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +submissions,imz_submissions_submitted_by_fk,FOREIGN KEY,submitted_by -> users.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +submissions,imz_submissions_pk,PRIMARY KEY,id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +submissions,imz_submissions_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +submissions,imz_submissions_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +submissions,imz_submissions_check_03,CHECK,"status IN ('draft','submitted','accepted','rejected')",database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +aftercare_plans,imz_aftercare_plans_investor_id_fk,FOREIGN KEY,investor_id -> investors.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +aftercare_plans,imz_aftercare_plans_project_id_fk,FOREIGN KEY,project_id -> projects.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +aftercare_plans,imz_aftercare_plans_owner_user_id_fk,FOREIGN KEY,owner_user_id -> users.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +aftercare_plans,imz_aftercare_plans_pk,PRIMARY KEY,id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +aftercare_plans,imz_aftercare_plans_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +aftercare_plans,imz_aftercare_plans_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +aftercare_plans,imz_aftercare_plans_check_03,CHECK,"status IN ('draft','active','closed')",database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +constraints,imz_constraints_aftercare_plan_id_fk,FOREIGN KEY,aftercare_plan_id -> aftercare_plans.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +constraints,imz_constraints_pk,PRIMARY KEY,id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +constraints,imz_constraints_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +constraints,imz_constraints_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +constraints,imz_constraints_check_03,CHECK,"severity IN ('low','medium','high','critical')",database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +constraints,imz_constraints_check_04,CHECK,"status IN ('open','mitigating','resolved')",database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +escalations,imz_escalations_constraint_id_fk,FOREIGN KEY,constraint_id -> constraints.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +escalations,imz_escalations_escalated_by_fk,FOREIGN KEY,escalated_by -> users.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +escalations,imz_escalations_pk,PRIMARY KEY,id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +escalations,imz_escalations_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +escalations,imz_escalations_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +escalations,imz_escalations_check_03,CHECK,"level IN ('operational','management','executive')",database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +escalations,imz_escalations_check_04,CHECK,"status IN ('open','acknowledged','closed')",database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +skills,imz_skills_pk,PRIMARY KEY,id,database/migrations/2026_09_24_009000_investmoz_education.php +skills,imz_skills_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_009000_investmoz_education.php +skills,imz_skills_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_009000_investmoz_education.php +occupations,imz_occupations_pk,PRIMARY KEY,id,database/migrations/2026_09_24_009000_investmoz_education.php +occupations,imz_occupations_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_009000_investmoz_education.php +occupations,imz_occupations_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_009000_investmoz_education.php +training_needs,imz_training_needs_project_id_fk,FOREIGN KEY,project_id -> projects.id,database/migrations/2026_09_24_009000_investmoz_education.php +training_needs,imz_training_needs_occupation_id_fk,FOREIGN KEY,occupation_id -> occupations.id,database/migrations/2026_09_24_009000_investmoz_education.php +training_needs,imz_training_needs_skill_id_fk,FOREIGN KEY,skill_id -> skills.id,database/migrations/2026_09_24_009000_investmoz_education.php +training_needs,imz_training_needs_pk,PRIMARY KEY,id,database/migrations/2026_09_24_009000_investmoz_education.php +training_needs,imz_training_needs_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_009000_investmoz_education.php +training_needs,imz_training_needs_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_009000_investmoz_education.php +training_needs,imz_training_needs_check_03,CHECK,quantity>0,database/migrations/2026_09_24_009000_investmoz_education.php +training_needs,imz_training_needs_check_04,CHECK,"priority IN ('Must','Should','Could')",database/migrations/2026_09_24_009000_investmoz_education.php +training_providers,imz_training_providers_organization_id_fk,FOREIGN KEY,organization_id -> organizations.id,database/migrations/2026_09_24_009000_investmoz_education.php +training_providers,imz_training_providers_pk,PRIMARY KEY,id,database/migrations/2026_09_24_009000_investmoz_education.php +training_providers,imz_training_providers_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_009000_investmoz_education.php +training_providers,imz_training_providers_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_009000_investmoz_education.php +training_providers,imz_training_providers_check_03,CHECK,"status IN ('candidate','approved','inactive')",database/migrations/2026_09_24_009000_investmoz_education.php +courses,imz_courses_provider_id_fk,FOREIGN KEY,provider_id -> training_providers.id,database/migrations/2026_09_24_009000_investmoz_education.php +courses,imz_courses_pk,PRIMARY KEY,id,database/migrations/2026_09_24_009000_investmoz_education.php +courses,imz_courses_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_009000_investmoz_education.php +courses,imz_courses_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_009000_investmoz_education.php +courses,imz_courses_check_03,CHECK,"status IN ('draft','published','retired')",database/migrations/2026_09_24_009000_investmoz_education.php +education_opportunities,imz_education_opportunities_course_id_fk,FOREIGN KEY,course_id -> courses.id,database/migrations/2026_09_24_009000_investmoz_education.php +education_opportunities,imz_education_opportunities_geo_location_id_fk,FOREIGN KEY,geo_location_id -> geo_locations.id,database/migrations/2026_09_24_009000_investmoz_education.php +education_opportunities,imz_education_opportunities_pk,PRIMARY KEY,id,database/migrations/2026_09_24_009000_investmoz_education.php +education_opportunities,imz_education_opportunities_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_009000_investmoz_education.php +education_opportunities,imz_education_opportunities_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_009000_investmoz_education.php +education_opportunities,imz_education_opportunities_check_03,CHECK,capacity>=0,database/migrations/2026_09_24_009000_investmoz_education.php +education_opportunities,imz_education_opportunities_check_04,CHECK,"status IN ('planned','open','closed','cancelled')",database/migrations/2026_09_24_009000_investmoz_education.php +education_opportunities,imz_education_opportunities_check_05,CHECK,end_date IS NULL OR end_date>=start_date,database/migrations/2026_09_24_009000_investmoz_education.php +project_documents,imz_project_documents_project_id_fk,FOREIGN KEY,project_id -> projects.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +project_documents,imz_project_documents_document_id_fk,FOREIGN KEY,document_id -> documents.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +project_documents,imz_project_documents_pk,PRIMARY KEY,"project_id, document_id",database/migrations/2026_09_24_009500_investmoz_document_links.php +investor_documents,imz_investor_documents_investor_id_fk,FOREIGN KEY,investor_id -> investors.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +investor_documents,imz_investor_documents_document_id_fk,FOREIGN KEY,document_id -> documents.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +investor_documents,imz_investor_documents_pk,PRIMARY KEY,"investor_id, document_id",database/migrations/2026_09_24_009500_investmoz_document_links.php +journey_documents,imz_journey_documents_journey_id_fk,FOREIGN KEY,journey_id -> investor_journeys.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +journey_documents,imz_journey_documents_document_id_fk,FOREIGN KEY,document_id -> documents.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +journey_documents,imz_journey_documents_pk,PRIMARY KEY,"journey_id, document_id",database/migrations/2026_09_24_009500_investmoz_document_links.php +aftercare_documents,imz_aftercare_documents_aftercare_plan_id_fk,FOREIGN KEY,aftercare_plan_id -> aftercare_plans.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +aftercare_documents,imz_aftercare_documents_document_id_fk,FOREIGN KEY,document_id -> documents.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +aftercare_documents,imz_aftercare_documents_pk,PRIMARY KEY,"aftercare_plan_id, document_id",database/migrations/2026_09_24_009500_investmoz_document_links.php +education_documents,imz_education_documents_education_opportunity_id_fk,FOREIGN KEY,education_opportunity_id -> education_opportunities.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +education_documents,imz_education_documents_document_id_fk,FOREIGN KEY,document_id -> documents.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +education_documents,imz_education_documents_pk,PRIMARY KEY,"education_opportunity_id, document_id",database/migrations/2026_09_24_009500_investmoz_document_links.php +audit_events,imz_audit_events_actor_user_id_fk,FOREIGN KEY,actor_user_id -> users.id,database/migrations/2026_09_24_010000_investmoz_governance_ai.php +audit_events,imz_audit_events_pk,PRIMARY KEY,id,database/migrations/2026_09_24_010000_investmoz_governance_ai.php +audit_events,imz_audit_events_check_01,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_010000_investmoz_governance_ai.php +outbox_events,imz_outbox_events_pk,PRIMARY KEY,id,database/migrations/2026_09_24_011000_investmoz_integrations.php +outbox_events,imz_outbox_events_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_011000_investmoz_integrations.php +outbox_events,imz_outbox_events_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_011000_investmoz_integrations.php +outbox_events,imz_outbox_events_check_03,CHECK,"status IN ('pending','published','failed')",database/migrations/2026_09_24_011000_investmoz_integrations.php +inbox_events,imz_inbox_events_pk,PRIMARY KEY,id,database/migrations/2026_09_24_011000_investmoz_integrations.php +inbox_events,imz_inbox_events_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_011000_investmoz_integrations.php +inbox_events,imz_inbox_events_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_011000_investmoz_integrations.php +inbox_events,imz_inbox_events_check_03,CHECK,"status IN ('received','processed','failed')",database/migrations/2026_09_24_011000_investmoz_integrations.php +sync_runs,imz_sync_runs_pk,PRIMARY KEY,id,database/migrations/2026_09_24_011000_investmoz_integrations.php +sync_runs,imz_sync_runs_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_011000_investmoz_integrations.php +sync_runs,imz_sync_runs_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_011000_investmoz_integrations.php +sync_runs,imz_sync_runs_check_03,CHECK,"direction IN ('inbound','outbound')",database/migrations/2026_09_24_011000_investmoz_integrations.php +sync_runs,imz_sync_runs_check_04,CHECK,"status IN ('running','succeeded','failed','cancelled')",database/migrations/2026_09_24_011000_investmoz_integrations.php +reconciliation_issues,imz_reconciliation_issues_sync_run_id_fk,FOREIGN KEY,sync_run_id -> sync_runs.id,database/migrations/2026_09_24_011000_investmoz_integrations.php +reconciliation_issues,imz_reconciliation_issues_pk,PRIMARY KEY,id,database/migrations/2026_09_24_011000_investmoz_integrations.php +reconciliation_issues,imz_reconciliation_issues_check_01,CHECK,version_no > 0,database/migrations/2026_09_24_011000_investmoz_integrations.php +reconciliation_issues,imz_reconciliation_issues_check_02,CHECK,"data_classification IN ('public','internal','confidential','restricted')",database/migrations/2026_09_24_011000_investmoz_integrations.php +reconciliation_issues,imz_reconciliation_issues_check_03,CHECK,"issue_type IN ('missing','duplicate','orphan','checksum','geometry','value')",database/migrations/2026_09_24_011000_investmoz_integrations.php +reconciliation_issues,imz_reconciliation_issues_check_04,CHECK,"severity IN ('warning','error')",database/migrations/2026_09_24_011000_investmoz_integrations.php +reconciliation_issues,imz_reconciliation_issues_check_05,CHECK,"status IN ('open','accepted','resolved')",database/migrations/2026_09_24_011000_investmoz_integrations.php diff --git a/docs/investmoz/data-dictionary.csv b/docs/investmoz/data-dictionary.csv new file mode 100644 index 00000000..6cfc9690 --- /dev/null +++ b/docs/investmoz/data-dictionary.csv @@ -0,0 +1,685 @@ +Tabela,Campo,Tipo PostgreSQL,Nulo,Predefinição,PK/FK,Único,Restrição/índice,Descrição,Enumeração CHECK,Calculado,Timestamp técnico,Soft delete,Migration,Observações +users,id,bigint,Não,nextval('users_id_seq'::regclass),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +users,name,character varying(255),Não,Sem predefinição,,Não,,name,Não,Não,False,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +users,email,character varying(255),Não,Sem predefinição,,users_email_unique,INDEX users_email_unique btree,email,Não,Não,False,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +users,email_verified_at,timestamp(0) without time zone,Sim,NULL,,Não,,email verified at,Não,Não,False,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +users,password,character varying(255),Não,Sem predefinição,,Não,,password,Não,Não,False,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +users,remember_token,character varying(100),Sim,NULL,,Não,,remember token,Não,Não,False,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +users,created_at,timestamp(0) without time zone,Sim,NULL,,Não,,Instante de criação,Não,Não,True,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +users,updated_at,timestamp(0) without time zone,Sim,NULL,,Não,,Instante da última actualização,Não,Não,True,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +users,two_factor_secret,text,Sim,NULL,,Não,,two factor secret,Não,Não,False,False,database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php,Legado preservado +users,two_factor_recovery_codes,text,Sim,NULL,,Não,,two factor recovery codes,Não,Não,False,False,database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php,Legado preservado +users,two_factor_confirmed_at,timestamp(0) without time zone,Sim,NULL,,Não,,two factor confirmed at,Não,Não,False,False,database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php,Legado preservado +password_reset_tokens,email,character varying(255),Não,Sem predefinição,PK,PRIMARY KEY,,email,Não,Não,False,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +password_reset_tokens,token,character varying(255),Não,Sem predefinição,,Não,,token,Não,Não,False,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +password_reset_tokens,created_at,timestamp(0) without time zone,Sim,NULL,,Não,,Instante de criação,Não,Não,True,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +sessions,id,character varying(255),Não,Sem predefinição,PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +sessions,user_id,bigint,Sim,NULL,,Não,INDEX sessions_user_id_index btree,user id,Não,Não,False,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +sessions,ip_address,character varying(45),Sim,NULL,,Não,,ip address,Não,Não,False,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +sessions,user_agent,text,Sim,NULL,,Não,,user agent,Não,Não,False,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +sessions,payload,text,Não,Sem predefinição,,Não,,Conteúdo da submissão/evento; contrato por tipo pendente,Não,Não,False,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +sessions,last_activity,integer,Não,Sem predefinição,,Não,INDEX sessions_last_activity_index btree,last activity,Não,Não,False,False,database/migrations/0001_01_01_000000_create_users_table.php,Legado preservado +cache,key,character varying(255),Não,Sem predefinição,PK,PRIMARY KEY,,key,Não,Não,False,False,database/migrations/0001_01_01_000001_create_cache_table.php,Legado preservado +cache,value,text,Não,Sem predefinição,,Não,,value,Não,Não,False,False,database/migrations/0001_01_01_000001_create_cache_table.php,Legado preservado +cache,expiration,bigint,Não,Sem predefinição,,Não,INDEX cache_expiration_index btree,expiration,Não,Não,False,False,database/migrations/0001_01_01_000001_create_cache_table.php,Legado preservado +cache_locks,key,character varying(255),Não,Sem predefinição,PK,PRIMARY KEY,,key,Não,Não,False,False,database/migrations/0001_01_01_000001_create_cache_table.php,Legado preservado +cache_locks,owner,character varying(255),Não,Sem predefinição,,Não,,owner,Não,Não,False,False,database/migrations/0001_01_01_000001_create_cache_table.php,Legado preservado +cache_locks,expiration,bigint,Não,Sem predefinição,,Não,INDEX cache_locks_expiration_index btree,expiration,Não,Não,False,False,database/migrations/0001_01_01_000001_create_cache_table.php,Legado preservado +jobs,id,bigint,Não,nextval('jobs_id_seq'::regclass),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +jobs,queue,character varying(255),Não,Sem predefinição,,Não,INDEX jobs_queue_index btree,queue,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +jobs,payload,text,Não,Sem predefinição,,Não,,Conteúdo da submissão/evento; contrato por tipo pendente,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +jobs,attempts,smallint,Não,Sem predefinição,,Não,,attempts,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +jobs,reserved_at,integer,Sim,NULL,,Não,,reserved at,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +jobs,available_at,integer,Não,Sem predefinição,,Não,,available at,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +jobs,created_at,integer,Não,Sem predefinição,,Não,,Instante de criação,Não,Não,True,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +job_batches,id,character varying(255),Não,Sem predefinição,PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +job_batches,name,character varying(255),Não,Sem predefinição,,Não,,name,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +job_batches,total_jobs,integer,Não,Sem predefinição,,Não,,total jobs,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +job_batches,pending_jobs,integer,Não,Sem predefinição,,Não,,pending jobs,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +job_batches,failed_jobs,integer,Não,Sem predefinição,,Não,,failed jobs,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +job_batches,failed_job_ids,text,Não,Sem predefinição,,Não,,failed job ids,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +job_batches,options,text,Sim,NULL,,Não,,options,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +job_batches,cancelled_at,integer,Sim,NULL,,Não,,cancelled at,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +job_batches,created_at,integer,Não,Sem predefinição,,Não,,Instante de criação,Não,Não,True,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +job_batches,finished_at,integer,Sim,NULL,,Não,,finished at,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +failed_jobs,id,bigint,Não,nextval('failed_jobs_id_seq'::regclass),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +failed_jobs,uuid,character varying(255),Não,Sem predefinição,,failed_jobs_uuid_unique,INDEX failed_jobs_uuid_unique btree,uuid,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +failed_jobs,connection,character varying(255),Não,Sem predefinição,,Não,INDEX failed_jobs_connection_queue_failed_at_index btree,connection,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +failed_jobs,queue,character varying(255),Não,Sem predefinição,,Não,INDEX failed_jobs_connection_queue_failed_at_index btree,queue,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +failed_jobs,payload,text,Não,Sem predefinição,,Não,,Conteúdo da submissão/evento; contrato por tipo pendente,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +failed_jobs,exception,text,Não,Sem predefinição,,Não,,exception,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +failed_jobs,failed_at,timestamp(0) without time zone,Não,CURRENT_TIMESTAMP,,Não,INDEX failed_jobs_connection_queue_failed_at_index btree,failed at,Não,Não,False,False,database/migrations/0001_01_01_000002_create_jobs_table.php,Legado preservado +passkeys,id,bigint,Não,nextval('passkeys_id_seq'::regclass),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2024_01_01_000000_create_passkeys_table.php,Legado preservado +passkeys,user_id,bigint,Não,Sem predefinição,FK -> users.id ON UPDATE NO ACTION ON DELETE CASCADE,Não,INDEX passkeys_user_id_index btree,user id,Não,Não,False,False,database/migrations/2024_01_01_000000_create_passkeys_table.php,Legado preservado +passkeys,name,character varying(255),Não,Sem predefinição,,Não,,name,Não,Não,False,False,database/migrations/2024_01_01_000000_create_passkeys_table.php,Legado preservado +passkeys,credential_id,character varying(255),Não,Sem predefinição,,passkeys_credential_id_unique,INDEX passkeys_credential_id_unique btree,credential id,Não,Não,False,False,database/migrations/2024_01_01_000000_create_passkeys_table.php,Legado preservado +passkeys,credential,json,Não,Sem predefinição,,Não,,credential,Não,Não,False,False,database/migrations/2024_01_01_000000_create_passkeys_table.php,Legado preservado +passkeys,last_used_at,timestamp(0) without time zone,Sim,NULL,,Não,,last used at,Não,Não,False,False,database/migrations/2024_01_01_000000_create_passkeys_table.php,Legado preservado +passkeys,created_at,timestamp(0) without time zone,Sim,NULL,,Não,,Instante de criação,Não,Não,True,False,database/migrations/2024_01_01_000000_create_passkeys_table.php,Legado preservado +passkeys,updated_at,timestamp(0) without time zone,Sim,NULL,,Não,,Instante da última actualização,Não,Não,True,False,database/migrations/2024_01_01_000000_create_passkeys_table.php,Legado preservado +sectors,id,bigint,Não,nextval('sectors_id_seq'::regclass),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_01_01_000001_create_sectors_table.php,Legado preservado +sectors,name,character varying(150),Não,Sem predefinição,,Não,,name,Não,Não,False,False,database/migrations/2026_01_01_000001_create_sectors_table.php,Legado preservado +sectors,slug,character varying(180),Não,Sem predefinição,,sectors_slug_unique,INDEX sectors_slug_unique btree; INDEX sectors_slug_index btree,slug,Não,Não,False,False,database/migrations/2026_01_01_000001_create_sectors_table.php,Legado preservado +sectors,short_description,character varying(255),Sim,NULL,,Não,,short description,Não,Não,False,False,database/migrations/2026_01_01_000001_create_sectors_table.php,Legado preservado +sectors,description,text,Sim,NULL,,Não,,description,Não,Não,False,False,database/migrations/2026_01_01_000001_create_sectors_table.php,Legado preservado +sectors,icon,character varying(100),Sim,NULL,,Não,,icon,Não,Não,False,False,database/migrations/2026_01_01_000001_create_sectors_table.php,Legado preservado +sectors,sort_order,integer,Não,0,,Não,,sort order,Não,Não,False,False,database/migrations/2026_01_01_000001_create_sectors_table.php,Legado preservado +sectors,is_active,boolean,Não,true,,Não,INDEX sectors_is_active_index btree,is active,Não,Não,False,False,database/migrations/2026_01_01_000001_create_sectors_table.php,Legado preservado +sectors,published_at,timestamp(0) without time zone,Sim,NULL,,Não,,published at,Não,Não,False,False,database/migrations/2026_01_01_000001_create_sectors_table.php,Legado preservado +sectors,created_at,timestamp(0) without time zone,Sim,NULL,,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_01_01_000001_create_sectors_table.php,Legado preservado +sectors,updated_at,timestamp(0) without time zone,Sim,NULL,,Não,,Instante da última actualização,Não,Não,True,False,database/migrations/2026_01_01_000001_create_sectors_table.php,Legado preservado +sectors,deleted_at,timestamp(0) without time zone,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_01_01_000001_create_sectors_table.php,Legado preservado +opportunities,id,bigint,Não,nextval('opportunities_id_seq'::regclass),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,sector_id,bigint,Não,Sem predefinição,FK -> sectors.id ON UPDATE CASCADE ON DELETE RESTRICT,Não,INDEX opportunities_sector_id_index btree,sector id,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,reference,character varying(80),Não,Sem predefinição,,opportunities_reference_unique,INDEX opportunities_reference_unique btree; INDEX opportunities_reference_index btree,reference,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,title,character varying(200),Não,Sem predefinição,,Não,,title,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,slug,character varying(220),Não,Sem predefinição,,opportunities_slug_unique,INDEX opportunities_slug_unique btree; INDEX opportunities_slug_index btree,slug,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,summary,character varying(500),Sim,NULL,,Não,,summary,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,description,text,Sim,NULL,,Não,,description,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,province,character varying(100),Sim,NULL,,Não,INDEX opportunities_province_index btree,province,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,district,character varying(100),Sim,NULL,,Não,,district,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,promoter_name,character varying(200),Sim,NULL,,Não,,promoter name,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,promoter_type,character varying(100),Sim,NULL,,Não,,promoter type,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,maturity_level,smallint,Sim,NULL,,Não,INDEX opportunities_maturity_level_index btree,maturity level,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,maturity_label,character varying(120),Sim,NULL,,Não,,maturity label,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,estimated_investment_amount,"numeric(18,2)",Sim,NULL,,Não,,estimated investment amount,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,estimated_investment_currency,character varying(10),Não,'USD',,Não,,estimated investment currency,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,financing_modality,character varying(120),Sim,NULL,,Não,,financing modality,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,expected_impact,text,Sim,NULL,,Não,,expected impact,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,status,character varying(50),Não,'draft',,Não,INDEX opportunities_status_index btree,status,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,is_featured,boolean,Não,false,,Não,INDEX opportunities_is_featured_index btree,is featured,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,published_at,timestamp(0) without time zone,Sim,NULL,,Não,,published at,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,last_validated_at,timestamp(0) without time zone,Sim,NULL,,Não,,last validated at,Não,Não,False,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,created_at,timestamp(0) without time zone,Sim,NULL,,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,updated_at,timestamp(0) without time zone,Sim,NULL,,Não,,Instante da última actualização,Não,Não,True,False,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +opportunities,deleted_at,timestamp(0) without time zone,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_01_01_000002_create_opportunities_table.php,Legado preservado +interest_requests,id,bigint,Não,nextval('interest_requests_id_seq'::regclass),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,opportunity_id,bigint,Sim,NULL,FK -> opportunities.id ON UPDATE CASCADE ON DELETE SET NULL,Não,,opportunity id,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,reference,character varying(80),Não,Sem predefinição,,interest_requests_reference_unique,INDEX interest_requests_reference_unique btree; INDEX interest_requests_reference_index btree,reference,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,full_name,character varying(150),Não,Sem predefinição,,Não,,full name,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,email,character varying(150),Não,Sem predefinição,,Não,INDEX interest_requests_email_index btree,email,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,phone,character varying(30),Sim,NULL,,Não,,phone,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,investor_type,character varying(80),Não,Sem predefinição,,Não,,investor type,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,country,character varying(100),Não,Sem predefinição,,Não,,country,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,sector_interest,character varying(150),Não,Sem predefinição,,Não,,sector interest,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,opportunity_reference,character varying(80),Sim,NULL,,Não,,opportunity reference,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,investment_range,character varying(80),Sim,NULL,,Não,,investment range,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,message,text,Não,Sem predefinição,,Não,,message,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,privacy_consent,boolean,Não,false,,Não,,privacy consent,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,consented_at,timestamp(0) without time zone,Sim,NULL,,Não,,consented at,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,status,character varying(50),Não,'received',,Não,INDEX interest_requests_status_index btree,status,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,source_page,character varying(255),Sim,NULL,,Não,,source page,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,ip_address,inet,Sim,NULL,,Não,,ip address,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,user_agent,text,Sim,NULL,,Não,,user agent,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,metadata,json,Sim,NULL,,Não,,Metadados flexíveis; schema aplicacional pendente,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,reviewed_at,timestamp(0) without time zone,Sim,NULL,,Não,,reviewed at,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,reviewed_by,bigint,Sim,NULL,FK -> users.id ON UPDATE NO ACTION ON DELETE SET NULL,Não,,reviewed by,Não,Não,False,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,created_at,timestamp(0) without time zone,Sim,NULL,,Não,INDEX interest_requests_created_at_index btree,Instante de criação,Não,Não,True,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,updated_at,timestamp(0) without time zone,Sim,NULL,,Não,,Instante da última actualização,Não,Não,True,False,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +interest_requests,deleted_at,timestamp(0) without time zone,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Legado preservado +organizations,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +organizations,source_system,text,Não,'investmoz',,imz_organizations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_organizations_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +organizations,external_id,text,Sim,NULL,,imz_organizations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_organizations_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +organizations,version_no,integer,Não,1,,Não,imz_organizations_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +organizations,data_classification,text,Não,'internal',,Não,"imz_organizations_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +organizations,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +organizations,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +organizations,updated_at,timestamptz,Não,now(),,Não,INDEX imz_organizations_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +organizations,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +organizations,name,text,Não,Sem predefinição,,Não,,name,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +organizations,organization_type,text,Não,Sem predefinição,,Não,"imz_organizations_check_03: organization_type IN ('public','private','ngo','education')",organization type,"organization_type IN ('public','private','ngo','education')",Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +memberships,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +memberships,source_system,text,Não,'investmoz',,imz_memberships_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_memberships_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +memberships,external_id,text,Sim,NULL,,imz_memberships_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_memberships_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +memberships,version_no,integer,Não,1,,Não,imz_memberships_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +memberships,data_classification,text,Não,'internal',,Não,"imz_memberships_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +memberships,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +memberships,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +memberships,updated_at,timestamptz,Não,now(),,Não,INDEX imz_memberships_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +memberships,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +memberships,user_id,bigint,Não,Sem predefinição,FK -> users.id ON UPDATE CASCADE ON DELETE CASCADE,imz_memberships_user_id_organization_id_uq (composta) WHERE deleted_at IS NULL,INDEX imz_memberships_user_id_idx btree; INDEX imz_memberships_user_id_organization_id_uq btree,user id,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +memberships,organization_id,uuid,Não,Sem predefinição,FK -> organizations.id ON UPDATE CASCADE ON DELETE CASCADE,imz_memberships_user_id_organization_id_uq (composta) WHERE deleted_at IS NULL,INDEX imz_memberships_organization_id_idx btree; INDEX imz_memberships_user_id_organization_id_uq btree,organization id,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +memberships,role,text,Não,Sem predefinição,,Não,"imz_memberships_check_03: role IN ('admin','manager','officer','viewer')",role,"role IN ('admin','manager','officer','viewer')",Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,source_system,text,Não,'investmoz',,imz_consents_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_consents_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,external_id,text,Sim,NULL,,imz_consents_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_consents_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,version_no,integer,Não,1,,Não,imz_consents_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,data_classification,text,Não,'internal',,Não,"imz_consents_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,updated_at,timestamptz,Não,now(),,Não,INDEX imz_consents_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,user_id,bigint,Sim,NULL,FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_consents_user_id_idx btree,user id,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,subject_ref,text,Não,Sem predefinição,,Não,,subject ref,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,purpose,text,Não,Sem predefinição,,Não,,purpose,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,status,text,Não,Sem predefinição,,Não,"imz_consents_check_03: status IN ('granted','withdrawn','expired') INDEX imz_consents_status_idx btree",status,"status IN ('granted','withdrawn','expired')",Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,granted_at,timestamptz,Sim,NULL,,Não,,granted at,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +consents,withdrawn_at,timestamptz,Sim,NULL,,Não,,withdrawn at,Não,Não,False,False,database/migrations/2026_09_24_001000_investmoz_identity.php,Reconciliado A.25/A.26 e A.31 +documents,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_002000_investmoz_content.php,Reconciliado A.25/A.26 e A.31 +documents,source_system,text,Não,'investmoz',,imz_documents_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_documents_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_002000_investmoz_content.php,Reconciliado A.25/A.26 e A.31 +documents,external_id,text,Sim,NULL,,imz_documents_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_documents_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_002000_investmoz_content.php,Reconciliado A.25/A.26 e A.31 +documents,version_no,integer,Não,1,,Não,imz_documents_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_002000_investmoz_content.php,Reconciliado A.25/A.26 e A.31 +documents,data_classification,text,Não,'internal',,Não,"imz_documents_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_002000_investmoz_content.php,Reconciliado A.25/A.26 e A.31 +documents,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_002000_investmoz_content.php,Reconciliado A.25/A.26 e A.31 +documents,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_002000_investmoz_content.php,Reconciliado A.25/A.26 e A.31 +documents,updated_at,timestamptz,Não,now(),,Não,INDEX imz_documents_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_002000_investmoz_content.php,Reconciliado A.25/A.26 e A.31 +documents,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_002000_investmoz_content.php,Reconciliado A.25/A.26 e A.31 +documents,file_name,text,Não,Sem predefinição,,Não,,file name,Não,Não,False,False,database/migrations/2026_09_24_002000_investmoz_content.php,Reconciliado A.25/A.26 e A.31 +documents,media_type,text,Não,Sem predefinição,,Não,,media type,Não,Não,False,False,database/migrations/2026_09_24_002000_investmoz_content.php,Reconciliado A.25/A.26 e A.31 +documents,storage_key,text,Não,Sem predefinição,,imz_documents_storage_key_uq,INDEX imz_documents_storage_key_uq btree,Chave única no armazenamento,Não,Não,False,False,database/migrations/2026_09_24_002000_investmoz_content.php,Reconciliado A.25/A.26 e A.31 +documents,sha256,char(64),Não,Sem predefinição,,Não,imz_documents_check_04: sha256 ~ '^[0-9a-f]{64}$',SHA-256 hexadecimal; 64 caracteres minúsculos,Não,Não,False,False,database/migrations/2026_09_24_002000_investmoz_content.php,Reconciliado A.25/A.26 e A.31 +documents,status,text,Não,Sem predefinição,,Não,"imz_documents_check_03: status IN ('active','quarantined','deleted') INDEX imz_documents_status_idx btree",status,"status IN ('active','quarantined','deleted')",Não,False,False,database/migrations/2026_09_24_002000_investmoz_content.php,Reconciliado A.25/A.26 e A.31 +geo_locations,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_locations,source_system,text,Não,'investmoz',,imz_geo_locations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_geo_locations_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_locations,external_id,text,Sim,NULL,,imz_geo_locations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_geo_locations_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_locations,version_no,integer,Não,1,,Não,imz_geo_locations_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_locations,data_classification,text,Não,'internal',,Não,"imz_geo_locations_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_locations,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_locations,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_locations,updated_at,timestamptz,Não,now(),,Não,INDEX imz_geo_locations_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_locations,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_locations,name,text,Não,Sem predefinição,,Não,,name,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_locations,location_type,text,Não,Sem predefinição,,imz_geo_locations_location_type_code_uq (composta) WHERE deleted_at IS NULL,"imz_geo_locations_check_03: location_type IN ('country','province','district','municipality','site') INDEX imz_geo_locations_location_type_code_uq btree",location type,"location_type IN ('country','province','district','municipality','site')",Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_locations,parent_id,uuid,Sim,NULL,FK -> geo_locations.id ON UPDATE CASCADE ON DELETE RESTRICT,Não,INDEX imz_geo_locations_parent_id_idx btree,parent id,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_locations,code,text,Não,Sem predefinição,,imz_geo_locations_location_type_code_uq (composta) WHERE deleted_at IS NULL,INDEX imz_geo_locations_location_type_code_uq btree,code,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_geometries,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_geometries,source_system,text,Não,'investmoz',,imz_geo_geometries_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_geo_geometries_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_geometries,external_id,text,Sim,NULL,,imz_geo_geometries_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_geo_geometries_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_geometries,version_no,integer,Não,1,,Não,imz_geo_geometries_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_geometries,data_classification,text,Não,'internal',,Não,"imz_geo_geometries_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_geometries,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_geometries,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_geometries,updated_at,timestamptz,Não,now(),,Não,INDEX imz_geo_geometries_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_geometries,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_geometries,geo_location_id,uuid,Não,Sem predefinição,FK -> geo_locations.id ON UPDATE CASCADE ON DELETE CASCADE,imz_geo_geometries_geo_location_id_geometry_type_uq (composta) WHERE deleted_at IS NULL,INDEX imz_geo_geometries_geo_location_id_idx btree; INDEX imz_geo_geometries_geo_location_id_geometry_type_uq btree,geo location id,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_geometries,geometry_type,text,Não,Sem predefinição,,imz_geo_geometries_geo_location_id_geometry_type_uq (composta) WHERE deleted_at IS NULL,"imz_geo_geometries_check_03: geometry_type IN ('point','line','polygon','multipolygon'); imz_geo_geometries_check_08: (geometry_type = 'point' AND GeometryType(geom) = 'POINT') OR (geometry_type = 'line' AND GeometryType(geom) = 'LINESTRING') OR (geometry_type = 'polygon' AND GeometryType(geom) = 'POLYGON') OR (geometry_type = 'multipolygon' AND GeometryType(geom) = 'MULTIPOLYGON') INDEX imz_geo_geometries_geo_location_id_geometry_type_uq btree",Tipo declarado coerente com GeometryType(geom),"geometry_type IN ('point','line','polygon','multipolygon')",Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_geometries,geom,"geometry(Geometry,4326)",Não,Sem predefinição,,Não,imz_geo_geometries_check_04: ST_SRID(geom) = 4326; imz_geo_geometries_check_05: ST_NDims(geom) = 2; imz_geo_geometries_check_06: NOT ST_IsEmpty(geom); imz_geo_geometries_check_07: ST_IsValid(geom); imz_geo_geometries_check_08: (geometry_type = 'point' AND GeometryType(geom) = 'POINT') OR (geometry_type = 'line' AND GeometryType(geom) = 'LINESTRING') OR (geometry_type = 'polygon' AND GeometryType(geom) = 'POLYGON') OR (geometry_type = 'multipolygon' AND GeometryType(geom) = 'MULTIPOLYGON'); imz_geo_geometries_check_09: ST_XMin(Box3D(geom)) >= -180 AND ST_XMax(Box3D(geom)) <= 180 AND ST_YMin(Box3D(geom)) >= -90 AND ST_YMax(Box3D(geom)) <= 90 INDEX imz_geo_geometries_geom_idx gist,"Geometria 2D WGS84 SRID 4326, válida e não vazia",Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +geo_geometries,properties,jsonb,Não,'{}'::jsonb,,Não,INDEX imz_geo_geometries_properties_idx gin,Propriedades geográficas; schema aplicacional pendente,Não,Não,False,False,database/migrations/2026_09_24_003000_investmoz_geography.php,Reconciliado A.25/A.26 e A.31 +projects,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,source_system,text,Não,'investmoz',,imz_projects_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_projects_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,external_id,text,Sim,NULL,,imz_projects_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_projects_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,version_no,integer,Não,1,,Não,imz_projects_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,data_classification,text,Não,'internal',,Não,"imz_projects_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,updated_at,timestamptz,Não,now(),,Não,INDEX imz_projects_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,code,text,Não,Sem predefinição,,imz_projects_code_uq WHERE deleted_at IS NULL,INDEX imz_projects_code_uq btree,code,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,name,text,Não,Sem predefinição,,Não,,name,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,status,text,Não,Sem predefinição,,Não,"imz_projects_check_03: status IN ('draft','planned','active','suspended','completed','cancelled') INDEX imz_projects_status_idx btree",status,"status IN ('draft','planned','active','suspended','completed','cancelled')",Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,description,text,Sim,NULL,,Não,,description,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,budget_amount,"numeric(20,2)",Sim,NULL,,Não,imz_projects_check_04: budget_amount IS NULL OR budget_amount >= 0; imz_projects_check_06: (budget_amount IS NULL) = (budget_currency IS NULL),Orçamento exacto não negativo,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,budget_currency,char(3),Sim,NULL,,Não,imz_projects_check_06: (budget_amount IS NULL) = (budget_currency IS NULL); imz_projects_check_07: budget_currency IS NULL OR budget_currency ~ '^[A-Z]{3}$',Código monetário do orçamento; lista ISO autorizada pendente,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,start_date,date,Sim,NULL,,Não,imz_projects_check_05: end_date IS NULL OR start_date IS NULL OR end_date >= start_date,start date,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,end_date,date,Sim,NULL,,Não,imz_projects_check_05: end_date IS NULL OR start_date IS NULL OR end_date >= start_date,end date,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +projects,metadata,jsonb,Não,'{}'::jsonb,,Não,INDEX imz_projects_metadata_idx gin,Metadados flexíveis; schema aplicacional pendente,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +project_locations,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +project_locations,source_system,text,Não,'investmoz',,imz_project_locations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_project_locations_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +project_locations,external_id,text,Sim,NULL,,imz_project_locations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_project_locations_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +project_locations,version_no,integer,Não,1,,Não,imz_project_locations_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +project_locations,data_classification,text,Não,'internal',,Não,"imz_project_locations_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +project_locations,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +project_locations,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +project_locations,updated_at,timestamptz,Não,now(),,Não,INDEX imz_project_locations_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +project_locations,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +project_locations,project_id,uuid,Não,Sem predefinição,FK -> projects.id ON UPDATE CASCADE ON DELETE CASCADE,imz_project_locations_project_id_geo_location_id_role_uq (composta) WHERE deleted_at IS NULL,INDEX imz_project_locations_project_id_idx btree; INDEX imz_project_locations_project_id_geo_location_id_role_uq btree,project id,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +project_locations,geo_location_id,uuid,Não,Sem predefinição,FK -> geo_locations.id ON UPDATE CASCADE ON DELETE RESTRICT,imz_project_locations_project_id_geo_location_id_role_uq (composta) WHERE deleted_at IS NULL,INDEX imz_project_locations_geo_location_id_idx btree; INDEX imz_project_locations_project_id_geo_location_id_role_uq btree,geo location id,Não,Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +project_locations,role,text,Não,Sem predefinição,,imz_project_locations_project_id_geo_location_id_role_uq (composta) WHERE deleted_at IS NULL,"imz_project_locations_check_03: role IN ('primary','secondary','impact') INDEX imz_project_locations_project_id_geo_location_id_role_uq btree",role,"role IN ('primary','secondary','impact')",Não,False,False,database/migrations/2026_09_24_004000_investmoz_projects.php,Reconciliado A.25/A.26 e A.31 +economic_clusters,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +economic_clusters,source_system,text,Não,'investmoz',,imz_economic_clusters_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_economic_clusters_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +economic_clusters,external_id,text,Sim,NULL,,imz_economic_clusters_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_economic_clusters_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +economic_clusters,version_no,integer,Não,1,,Não,imz_economic_clusters_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +economic_clusters,data_classification,text,Não,'internal',,Não,"imz_economic_clusters_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +economic_clusters,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +economic_clusters,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +economic_clusters,updated_at,timestamptz,Não,now(),,Não,INDEX imz_economic_clusters_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +economic_clusters,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +economic_clusters,code,text,Não,Sem predefinição,,imz_economic_clusters_code_uq WHERE deleted_at IS NULL,INDEX imz_economic_clusters_code_uq btree,code,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +economic_clusters,name,text,Não,Sem predefinição,,Não,,name,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +economic_clusters,sector,text,Não,Sem predefinição,,Não,,sector,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +economic_clusters,status,text,Não,Sem predefinição,,Não,"imz_economic_clusters_check_03: status IN ('planned','active','inactive') INDEX imz_economic_clusters_status_idx btree",status,"status IN ('planned','active','inactive')",Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +economic_clusters,metadata,jsonb,Não,'{}'::jsonb,,Não,,Metadados flexíveis; schema aplicacional pendente,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chains,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chains,source_system,text,Não,'investmoz',,imz_value_chains_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_value_chains_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chains,external_id,text,Sim,NULL,,imz_value_chains_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_value_chains_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chains,version_no,integer,Não,1,,Não,imz_value_chains_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chains,data_classification,text,Não,'internal',,Não,"imz_value_chains_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chains,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chains,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chains,updated_at,timestamptz,Não,now(),,Não,INDEX imz_value_chains_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chains,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chains,cluster_id,uuid,Não,Sem predefinição,FK -> economic_clusters.id ON UPDATE CASCADE ON DELETE CASCADE,imz_value_chains_cluster_id_code_uq (composta) WHERE deleted_at IS NULL,INDEX imz_value_chains_cluster_id_idx btree; INDEX imz_value_chains_cluster_id_code_uq btree,cluster id,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chains,code,text,Não,Sem predefinição,,imz_value_chains_cluster_id_code_uq (composta) WHERE deleted_at IS NULL,INDEX imz_value_chains_cluster_id_code_uq btree,code,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chains,name,text,Não,Sem predefinição,,Não,,name,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chains,stage,text,Não,Sem predefinição,,Não,"imz_value_chains_check_03: stage IN ('input','production','processing','logistics','market')",stage,"stage IN ('input','production','processing','logistics','market')",Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chain_links,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chain_links,source_system,text,Não,'investmoz',,imz_value_chain_links_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_value_chain_links_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chain_links,external_id,text,Sim,NULL,,imz_value_chain_links_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_value_chain_links_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chain_links,version_no,integer,Não,1,,Não,imz_value_chain_links_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chain_links,data_classification,text,Não,'internal',,Não,"imz_value_chain_links_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chain_links,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chain_links,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chain_links,updated_at,timestamptz,Não,now(),,Não,INDEX imz_value_chain_links_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chain_links,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chain_links,from_value_chain_id,uuid,Não,Sem predefinição,FK -> value_chains.id ON UPDATE CASCADE ON DELETE CASCADE,imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c (composta) WHERE deleted_at IS NULL,imz_value_chain_links_check_04: from_value_chain_id <> to_value_chain_id INDEX imz_value_chain_links_from_value_chain_id_idx btree; INDEX imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c btree,from value chain id,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chain_links,to_value_chain_id,uuid,Não,Sem predefinição,FK -> value_chains.id ON UPDATE CASCADE ON DELETE CASCADE,imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c (composta) WHERE deleted_at IS NULL,imz_value_chain_links_check_04: from_value_chain_id <> to_value_chain_id INDEX imz_value_chain_links_to_value_chain_id_idx btree; INDEX imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c btree,to value chain id,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +value_chain_links,link_type,text,Não,Sem predefinição,,imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c (composta) WHERE deleted_at IS NULL,"imz_value_chain_links_check_03: link_type IN ('supplies','processes','distributes','supports') INDEX imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c btree",link type,"link_type IN ('supplies','processes','distributes','supports')",Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +cluster_projects,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +cluster_projects,source_system,text,Não,'investmoz',,imz_cluster_projects_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_cluster_projects_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +cluster_projects,external_id,text,Sim,NULL,,imz_cluster_projects_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_cluster_projects_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +cluster_projects,version_no,integer,Não,1,,Não,imz_cluster_projects_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +cluster_projects,data_classification,text,Não,'internal',,Não,"imz_cluster_projects_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +cluster_projects,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +cluster_projects,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +cluster_projects,updated_at,timestamptz,Não,now(),,Não,INDEX imz_cluster_projects_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +cluster_projects,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +cluster_projects,cluster_id,uuid,Não,Sem predefinição,FK -> economic_clusters.id ON UPDATE CASCADE ON DELETE CASCADE,imz_cluster_projects_cluster_id_project_id_uq (composta) WHERE deleted_at IS NULL,INDEX imz_cluster_projects_cluster_id_idx btree; INDEX imz_cluster_projects_cluster_id_project_id_uq btree,cluster id,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +cluster_projects,project_id,uuid,Não,Sem predefinição,FK -> projects.id ON UPDATE CASCADE ON DELETE CASCADE,imz_cluster_projects_cluster_id_project_id_uq (composta) WHERE deleted_at IS NULL,INDEX imz_cluster_projects_project_id_idx btree; INDEX imz_cluster_projects_cluster_id_project_id_uq btree,project id,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +cluster_projects,role,text,Não,Sem predefinição,,Não,"imz_cluster_projects_check_03: role IN ('anchor','complementary','supporting')",role,"role IN ('anchor','complementary','supporting')",Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +anchor_project_links,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +anchor_project_links,source_system,text,Não,'investmoz',,imz_anchor_project_links_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_anchor_project_links_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +anchor_project_links,external_id,text,Sim,NULL,,imz_anchor_project_links_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_anchor_project_links_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +anchor_project_links,version_no,integer,Não,1,,Não,imz_anchor_project_links_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +anchor_project_links,data_classification,text,Não,'internal',,Não,"imz_anchor_project_links_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +anchor_project_links,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +anchor_project_links,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +anchor_project_links,updated_at,timestamptz,Não,now(),,Não,INDEX imz_anchor_project_links_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +anchor_project_links,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +anchor_project_links,anchor_project_id,uuid,Não,Sem predefinição,FK -> projects.id ON UPDATE CASCADE ON DELETE CASCADE,imz_anchor_project_links_anchor_project_id_cluster_id_uq (composta) WHERE deleted_at IS NULL,INDEX imz_anchor_project_links_anchor_project_id_idx btree; INDEX imz_anchor_project_links_anchor_project_id_cluster_id_uq btree,anchor project id,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +anchor_project_links,cluster_id,uuid,Não,Sem predefinição,FK -> economic_clusters.id ON UPDATE CASCADE ON DELETE CASCADE,imz_anchor_project_links_anchor_project_id_cluster_id_uq (composta) WHERE deleted_at IS NULL,INDEX imz_anchor_project_links_cluster_id_idx btree; INDEX imz_anchor_project_links_anchor_project_id_cluster_id_uq btree,cluster id,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +anchor_project_links,rationale,text,Não,Sem predefinição,,Não,,rationale,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +complementary_project_links,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +complementary_project_links,source_system,text,Não,'investmoz',,imz_complementary_project_links_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_complementary_project_links_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +complementary_project_links,external_id,text,Sim,NULL,,imz_complementary_project_links_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_complementary_project_links_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +complementary_project_links,version_no,integer,Não,1,,Não,imz_complementary_project_links_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +complementary_project_links,data_classification,text,Não,'internal',,Não,"imz_complementary_project_links_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +complementary_project_links,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +complementary_project_links,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +complementary_project_links,updated_at,timestamptz,Não,now(),,Não,INDEX imz_complementary_project_links_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +complementary_project_links,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +complementary_project_links,anchor_project_id,uuid,Não,Sem predefinição,FK -> projects.id ON UPDATE CASCADE ON DELETE CASCADE,imz_complementary_project_links_anchor_project_id_co_90eddcd25a (composta) WHERE deleted_at IS NULL,imz_complementary_project_links_check_04: anchor_project_id<>complementary_project_id INDEX imz_complementary_project_links_anchor_project_id_idx btree; INDEX imz_complementary_project_links_anchor_project_id_co_90eddcd25a btree,anchor project id,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +complementary_project_links,complementary_project_id,uuid,Não,Sem predefinição,FK -> projects.id ON UPDATE CASCADE ON DELETE CASCADE,imz_complementary_project_links_anchor_project_id_co_90eddcd25a (composta) WHERE deleted_at IS NULL,imz_complementary_project_links_check_04: anchor_project_id<>complementary_project_id INDEX imz_complementary_project_links_complementary_project_id_idx btree; INDEX imz_complementary_project_links_anchor_project_id_co_90eddcd25a btree,complementary project id,Não,Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +complementary_project_links,relation_type,text,Não,Sem predefinição,,imz_complementary_project_links_anchor_project_id_co_90eddcd25a (composta) WHERE deleted_at IS NULL,"imz_complementary_project_links_check_03: relation_type IN ('dependency','enabler','supplier','market') INDEX imz_complementary_project_links_anchor_project_id_co_90eddcd25a btree",relation type,"relation_type IN ('dependency','enabler','supplier','market')",Não,False,False,database/migrations/2026_09_24_005000_investmoz_clusters.php,Reconciliado A.25/A.26 e A.31 +development_corridors,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +development_corridors,source_system,text,Não,'investmoz',,imz_development_corridors_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_development_corridors_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +development_corridors,external_id,text,Sim,NULL,,imz_development_corridors_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_development_corridors_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +development_corridors,version_no,integer,Não,1,,Não,imz_development_corridors_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +development_corridors,data_classification,text,Não,'internal',,Não,"imz_development_corridors_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +development_corridors,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +development_corridors,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +development_corridors,updated_at,timestamptz,Não,now(),,Não,INDEX imz_development_corridors_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +development_corridors,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +development_corridors,code,text,Não,Sem predefinição,,imz_development_corridors_code_uq WHERE deleted_at IS NULL,INDEX imz_development_corridors_code_uq btree,code,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +development_corridors,name,text,Não,Sem predefinição,,Não,,name,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +development_corridors,status,text,Não,Sem predefinição,,Não,"imz_development_corridors_check_03: status IN ('proposed','active','closed') INDEX imz_development_corridors_status_idx btree",status,"status IN ('proposed','active','closed')",Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +development_corridors,description,text,Sim,NULL,,Não,,description,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_locations,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_locations,source_system,text,Não,'investmoz',,imz_corridor_locations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_corridor_locations_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_locations,external_id,text,Sim,NULL,,imz_corridor_locations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_corridor_locations_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_locations,version_no,integer,Não,1,,Não,imz_corridor_locations_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_locations,data_classification,text,Não,'internal',,Não,"imz_corridor_locations_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_locations,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_locations,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_locations,updated_at,timestamptz,Não,now(),,Não,INDEX imz_corridor_locations_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_locations,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_locations,corridor_id,uuid,Não,Sem predefinição,FK -> development_corridors.id ON UPDATE CASCADE ON DELETE CASCADE,imz_corridor_locations_corridor_id_geo_location_id_uq (composta) WHERE deleted_at IS NULL; imz_corridor_locations_corridor_id_sequence_no_uq (composta) WHERE deleted_at IS NULL,INDEX imz_corridor_locations_corridor_id_idx btree; INDEX imz_corridor_locations_corridor_id_geo_location_id_uq btree; INDEX imz_corridor_locations_corridor_id_sequence_no_uq btree,corridor id,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_locations,geo_location_id,uuid,Não,Sem predefinição,FK -> geo_locations.id ON UPDATE CASCADE ON DELETE RESTRICT,imz_corridor_locations_corridor_id_geo_location_id_uq (composta) WHERE deleted_at IS NULL,INDEX imz_corridor_locations_geo_location_id_idx btree; INDEX imz_corridor_locations_corridor_id_geo_location_id_uq btree,geo location id,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_locations,sequence_no,integer,Não,Sem predefinição,,imz_corridor_locations_corridor_id_sequence_no_uq (composta) WHERE deleted_at IS NULL,imz_corridor_locations_check_03: sequence_no>0 INDEX imz_corridor_locations_corridor_id_sequence_no_uq btree,sequence no,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_projects,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_projects,source_system,text,Não,'investmoz',,imz_corridor_projects_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_corridor_projects_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_projects,external_id,text,Sim,NULL,,imz_corridor_projects_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_corridor_projects_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_projects,version_no,integer,Não,1,,Não,imz_corridor_projects_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_projects,data_classification,text,Não,'internal',,Não,"imz_corridor_projects_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_projects,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_projects,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_projects,updated_at,timestamptz,Não,now(),,Não,INDEX imz_corridor_projects_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_projects,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_projects,corridor_id,uuid,Não,Sem predefinição,FK -> development_corridors.id ON UPDATE CASCADE ON DELETE CASCADE,imz_corridor_projects_corridor_id_project_id_uq (composta) WHERE deleted_at IS NULL,INDEX imz_corridor_projects_corridor_id_idx btree; INDEX imz_corridor_projects_corridor_id_project_id_uq btree,corridor id,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_projects,project_id,uuid,Não,Sem predefinição,FK -> projects.id ON UPDATE CASCADE ON DELETE CASCADE,imz_corridor_projects_corridor_id_project_id_uq (composta) WHERE deleted_at IS NULL,INDEX imz_corridor_projects_project_id_idx btree; INDEX imz_corridor_projects_corridor_id_project_id_uq btree,project id,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_clusters,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_clusters,source_system,text,Não,'investmoz',,imz_corridor_clusters_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_corridor_clusters_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_clusters,external_id,text,Sim,NULL,,imz_corridor_clusters_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_corridor_clusters_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_clusters,version_no,integer,Não,1,,Não,imz_corridor_clusters_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_clusters,data_classification,text,Não,'internal',,Não,"imz_corridor_clusters_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_clusters,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_clusters,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_clusters,updated_at,timestamptz,Não,now(),,Não,INDEX imz_corridor_clusters_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_clusters,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_clusters,corridor_id,uuid,Não,Sem predefinição,FK -> development_corridors.id ON UPDATE CASCADE ON DELETE CASCADE,imz_corridor_clusters_corridor_id_cluster_id_uq (composta) WHERE deleted_at IS NULL,INDEX imz_corridor_clusters_corridor_id_idx btree; INDEX imz_corridor_clusters_corridor_id_cluster_id_uq btree,corridor id,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +corridor_clusters,cluster_id,uuid,Não,Sem predefinição,FK -> economic_clusters.id ON UPDATE CASCADE ON DELETE CASCADE,imz_corridor_clusters_corridor_id_cluster_id_uq (composta) WHERE deleted_at IS NULL,INDEX imz_corridor_clusters_cluster_id_idx btree; INDEX imz_corridor_clusters_corridor_id_cluster_id_uq btree,cluster id,Não,Não,False,False,database/migrations/2026_09_24_006000_investmoz_corridors.php,Reconciliado A.25/A.26 e A.31 +investors,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investors,source_system,text,Não,'investmoz',,imz_investors_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_investors_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investors,external_id,text,Sim,NULL,,imz_investors_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_investors_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investors,version_no,integer,Não,1,,Não,imz_investors_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investors,data_classification,text,Não,'internal',,Não,"imz_investors_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investors,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investors,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investors,updated_at,timestamptz,Não,now(),,Não,INDEX imz_investors_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investors,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investors,organization_id,uuid,Sim,NULL,FK -> organizations.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_investors_organization_id_idx btree,organization id,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investors,legal_name,text,Não,Sem predefinição,,Não,,legal name,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investors,investor_type,text,Não,Sem predefinição,,Não,"imz_investors_check_03: investor_type IN ('domestic','foreign','institutional','individual')",investor type,"investor_type IN ('domestic','foreign','institutional','individual')",Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investors,status,text,Não,Sem predefinição,,Não,"imz_investors_check_04: status IN ('prospect','active','inactive') INDEX imz_investors_status_idx btree",status,"status IN ('prospect','active','inactive')",Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +contacts,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +contacts,source_system,text,Não,'investmoz',,imz_contacts_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_contacts_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +contacts,external_id,text,Sim,NULL,,imz_contacts_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_contacts_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +contacts,version_no,integer,Não,1,,Não,imz_contacts_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +contacts,data_classification,text,Não,'internal',,Não,"imz_contacts_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +contacts,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +contacts,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +contacts,updated_at,timestamptz,Não,now(),,Não,INDEX imz_contacts_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +contacts,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +contacts,full_name,text,Não,Sem predefinição,,Não,,full name,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +contacts,email,text,Sim,NULL,,Não,,email,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +contacts,phone,text,Sim,NULL,,Não,,phone,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investor_contacts,investor_id,uuid,Não,Sem predefinição,PK; FK -> investors.id ON UPDATE CASCADE ON DELETE CASCADE,"PRIMARY KEY composta: investor_id, contact_id",INDEX imz_investor_contacts_investor_id_idx btree,investor id,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investor_contacts,contact_id,uuid,Não,Sem predefinição,PK; FK -> contacts.id ON UPDATE CASCADE ON DELETE CASCADE,"PRIMARY KEY composta: investor_id, contact_id",INDEX imz_investor_contacts_contact_id_idx btree,contact id,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investor_contacts,is_primary,boolean,Não,false,,Não,,Contacto principal deste investidor; limite quantitativo pendente,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investor_contacts,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investor_contacts,updated_at,timestamptz,Não,now(),,Não,INDEX imz_investor_contacts_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,source_system,text,Não,'investmoz',,imz_leads_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_leads_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,external_id,text,Sim,NULL,,imz_leads_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_leads_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,version_no,integer,Não,1,,Não,imz_leads_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,data_classification,text,Não,'internal',,Não,"imz_leads_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,updated_at,timestamptz,Não,now(),,Não,INDEX imz_leads_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,investor_id,uuid,Sim,NULL,FK -> investors.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_leads_investor_id_idx btree,investor id,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,owner_user_id,bigint,Sim,NULL,FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_leads_owner_user_id_idx btree,owner user id,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,title,text,Não,Sem predefinição,,Não,,title,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,status,text,Não,Sem predefinição,,Não,"imz_leads_check_03: status IN ('new','qualified','disqualified','converted') INDEX imz_leads_status_idx btree",status,"status IN ('new','qualified','disqualified','converted')",Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,estimated_amount,"numeric(20,2)",Sim,NULL,,Não,imz_leads_check_04: (estimated_amount IS NULL)=(currency IS NULL); imz_leads_check_05: estimated_amount IS NULL OR estimated_amount >= 0,Montante estimado exacto não negativo,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +leads,currency,char(3),Sim,NULL,,Não,imz_leads_check_04: (estimated_amount IS NULL)=(currency IS NULL); imz_leads_check_06: currency IS NULL OR currency ~ '^[A-Z]{3}$',Código monetário; três letras maiúsculas; lista ISO autorizada pendente,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,source_system,text,Não,'investmoz',,imz_crm_opportunities_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_crm_opportunities_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,external_id,text,Sim,NULL,,imz_crm_opportunities_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_crm_opportunities_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,version_no,integer,Não,1,,Não,imz_crm_opportunities_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,data_classification,text,Não,'internal',,Não,"imz_crm_opportunities_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,updated_at,timestamptz,Não,now(),,Não,INDEX imz_crm_opportunities_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,lead_id,uuid,Sim,NULL,FK -> leads.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_crm_opportunities_lead_id_idx btree,lead id,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,project_id,uuid,Sim,NULL,FK -> projects.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_crm_opportunities_project_id_idx btree,project id,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,owner_user_id,bigint,Sim,NULL,FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_crm_opportunities_owner_user_id_idx btree,owner user id,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,stage,text,Não,Sem predefinição,,Não,"imz_crm_opportunities_check_03: stage IN ('discovery','assessment','negotiation','approved','lost')",stage,"stage IN ('discovery','assessment','negotiation','approved','lost')",Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,amount,"numeric(20,2)",Sim,NULL,,Não,imz_crm_opportunities_check_05: (amount IS NULL)=(currency IS NULL); imz_crm_opportunities_check_06: amount IS NULL OR amount >= 0,Montante monetário exacto não negativo,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,currency,char(3),Sim,NULL,,Não,imz_crm_opportunities_check_05: (amount IS NULL)=(currency IS NULL); imz_crm_opportunities_check_07: currency IS NULL OR currency ~ '^[A-Z]{3}$',Código monetário; três letras maiúsculas; lista ISO autorizada pendente,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +crm_opportunities,probability,"numeric(5,2)",Sim,NULL,,Não,imz_crm_opportunities_check_04: probability BETWEEN 0 AND 100,probability,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,source_system,text,Não,'investmoz',,imz_activities_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_activities_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,external_id,text,Sim,NULL,,imz_activities_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_activities_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,version_no,integer,Não,1,,Não,imz_activities_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,data_classification,text,Não,'internal',,Não,"imz_activities_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,updated_at,timestamptz,Não,now(),,Não,INDEX imz_activities_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,investor_id,uuid,Sim,NULL,FK -> investors.id ON UPDATE CASCADE ON DELETE CASCADE,Não,INDEX imz_activities_investor_id_idx btree,investor id,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,opportunity_id,uuid,Sim,NULL,FK -> crm_opportunities.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_activities_opportunity_id_idx btree,opportunity id,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,actor_user_id,bigint,Sim,NULL,FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_activities_actor_user_id_idx btree,actor user id,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,activity_type,text,Não,Sem predefinição,,Não,"imz_activities_check_03: activity_type IN ('call','meeting','email','note')",activity type,"activity_type IN ('call','meeting','email','note')",Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,occurred_at,timestamptz,Não,Sem predefinição,,Não,,occurred at,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +activities,summary,text,Não,Sem predefinição,,Não,,summary,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +tasks,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +tasks,source_system,text,Não,'investmoz',,imz_tasks_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_tasks_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +tasks,external_id,text,Sim,NULL,,imz_tasks_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_tasks_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +tasks,version_no,integer,Não,1,,Não,imz_tasks_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +tasks,data_classification,text,Não,'internal',,Não,"imz_tasks_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +tasks,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +tasks,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +tasks,updated_at,timestamptz,Não,now(),,Não,INDEX imz_tasks_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +tasks,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +tasks,assignee_user_id,bigint,Sim,NULL,FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_tasks_assignee_user_id_idx btree,assignee user id,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +tasks,opportunity_id,uuid,Sim,NULL,FK -> crm_opportunities.id ON UPDATE CASCADE ON DELETE CASCADE,Não,INDEX imz_tasks_opportunity_id_idx btree,opportunity id,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +tasks,title,text,Não,Sem predefinição,,Não,,title,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +tasks,status,text,Não,Sem predefinição,,Não,"imz_tasks_check_03: status IN ('open','in_progress','done','cancelled') INDEX imz_tasks_status_idx btree",status,"status IN ('open','in_progress','done','cancelled')",Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +tasks,due_at,timestamptz,Sim,NULL,,Não,,due at,Não,Não,False,False,database/migrations/2026_09_24_007000_investmoz_crm.php,Reconciliado A.25/A.26 e A.31 +investor_journeys,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +investor_journeys,source_system,text,Não,'investmoz',,imz_investor_journeys_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_investor_journeys_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +investor_journeys,external_id,text,Sim,NULL,,imz_investor_journeys_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_investor_journeys_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +investor_journeys,version_no,integer,Não,1,,Não,imz_investor_journeys_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +investor_journeys,data_classification,text,Não,'internal',,Não,"imz_investor_journeys_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +investor_journeys,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +investor_journeys,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +investor_journeys,updated_at,timestamptz,Não,now(),,Não,INDEX imz_investor_journeys_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +investor_journeys,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +investor_journeys,investor_id,uuid,Não,Sem predefinição,FK -> investors.id ON UPDATE CASCADE ON DELETE CASCADE,Não,INDEX imz_investor_journeys_investor_id_idx btree,investor id,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +investor_journeys,opportunity_id,uuid,Sim,NULL,FK -> crm_opportunities.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_investor_journeys_opportunity_id_idx btree,opportunity id,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +investor_journeys,status,text,Não,Sem predefinição,,Não,"imz_investor_journeys_check_03: status IN ('started','active','completed','cancelled') INDEX imz_investor_journeys_status_idx btree",status,"status IN ('started','active','completed','cancelled')",Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +investor_journeys,started_at,timestamptz,Não,now(),,Não,,started at,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +journey_steps,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +journey_steps,source_system,text,Não,'investmoz',,imz_journey_steps_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_journey_steps_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +journey_steps,external_id,text,Sim,NULL,,imz_journey_steps_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_journey_steps_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +journey_steps,version_no,integer,Não,1,,Não,imz_journey_steps_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +journey_steps,data_classification,text,Não,'internal',,Não,"imz_journey_steps_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +journey_steps,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +journey_steps,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +journey_steps,updated_at,timestamptz,Não,now(),,Não,INDEX imz_journey_steps_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +journey_steps,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +journey_steps,journey_id,uuid,Não,Sem predefinição,FK -> investor_journeys.id ON UPDATE CASCADE ON DELETE CASCADE,imz_journey_steps_journey_id_sequence_no_uq (composta) WHERE deleted_at IS NULL; imz_journey_steps_journey_id_step_code_uq (composta) WHERE deleted_at IS NULL,INDEX imz_journey_steps_journey_id_idx btree; INDEX imz_journey_steps_journey_id_sequence_no_uq btree; INDEX imz_journey_steps_journey_id_step_code_uq btree,journey id,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +journey_steps,step_code,text,Não,Sem predefinição,,imz_journey_steps_journey_id_step_code_uq (composta) WHERE deleted_at IS NULL,INDEX imz_journey_steps_journey_id_step_code_uq btree,step code,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +journey_steps,sequence_no,integer,Não,Sem predefinição,,imz_journey_steps_journey_id_sequence_no_uq (composta) WHERE deleted_at IS NULL,imz_journey_steps_check_03: sequence_no>0 INDEX imz_journey_steps_journey_id_sequence_no_uq btree,sequence no,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +journey_steps,status,text,Não,Sem predefinição,,Não,"imz_journey_steps_check_04: status IN ('pending','active','completed','blocked') INDEX imz_journey_steps_status_idx btree",status,"status IN ('pending','active','completed','blocked')",Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +submissions,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +submissions,source_system,text,Não,'investmoz',,imz_submissions_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_submissions_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +submissions,external_id,text,Sim,NULL,,imz_submissions_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_submissions_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +submissions,version_no,integer,Não,1,,Não,imz_submissions_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +submissions,data_classification,text,Não,'internal',,Não,"imz_submissions_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +submissions,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +submissions,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +submissions,updated_at,timestamptz,Não,now(),,Não,INDEX imz_submissions_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +submissions,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +submissions,journey_step_id,uuid,Não,Sem predefinição,FK -> journey_steps.id ON UPDATE CASCADE ON DELETE CASCADE,Não,INDEX imz_submissions_journey_step_id_idx btree,journey step id,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +submissions,submitted_by,bigint,Sim,NULL,FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_submissions_submitted_by_idx btree,submitted by,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +submissions,status,text,Não,Sem predefinição,,Não,"imz_submissions_check_03: status IN ('draft','submitted','accepted','rejected') INDEX imz_submissions_status_idx btree",status,"status IN ('draft','submitted','accepted','rejected')",Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +submissions,payload,jsonb,Não,'{}'::jsonb,,Não,INDEX imz_submissions_payload_idx gin,Conteúdo da submissão/evento; contrato por tipo pendente,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +submissions,submitted_at,timestamptz,Sim,NULL,,Não,,submitted at,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +aftercare_plans,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +aftercare_plans,source_system,text,Não,'investmoz',,imz_aftercare_plans_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_aftercare_plans_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +aftercare_plans,external_id,text,Sim,NULL,,imz_aftercare_plans_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_aftercare_plans_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +aftercare_plans,version_no,integer,Não,1,,Não,imz_aftercare_plans_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +aftercare_plans,data_classification,text,Não,'internal',,Não,"imz_aftercare_plans_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +aftercare_plans,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +aftercare_plans,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +aftercare_plans,updated_at,timestamptz,Não,now(),,Não,INDEX imz_aftercare_plans_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +aftercare_plans,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +aftercare_plans,investor_id,uuid,Não,Sem predefinição,FK -> investors.id ON UPDATE CASCADE ON DELETE CASCADE,Não,INDEX imz_aftercare_plans_investor_id_idx btree,investor id,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +aftercare_plans,project_id,uuid,Sim,NULL,FK -> projects.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_aftercare_plans_project_id_idx btree,project id,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +aftercare_plans,owner_user_id,bigint,Sim,NULL,FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_aftercare_plans_owner_user_id_idx btree,owner user id,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +aftercare_plans,status,text,Não,Sem predefinição,,Não,"imz_aftercare_plans_check_03: status IN ('draft','active','closed') INDEX imz_aftercare_plans_status_idx btree",status,"status IN ('draft','active','closed')",Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +aftercare_plans,review_date,date,Sim,NULL,,Não,,review date,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +constraints,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +constraints,source_system,text,Não,'investmoz',,imz_constraints_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_constraints_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +constraints,external_id,text,Sim,NULL,,imz_constraints_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_constraints_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +constraints,version_no,integer,Não,1,,Não,imz_constraints_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +constraints,data_classification,text,Não,'internal',,Não,"imz_constraints_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +constraints,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +constraints,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +constraints,updated_at,timestamptz,Não,now(),,Não,INDEX imz_constraints_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +constraints,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +constraints,aftercare_plan_id,uuid,Não,Sem predefinição,FK -> aftercare_plans.id ON UPDATE CASCADE ON DELETE CASCADE,Não,INDEX imz_constraints_aftercare_plan_id_idx btree,aftercare plan id,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +constraints,category,text,Não,Sem predefinição,,Não,,category,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +constraints,description,text,Não,Sem predefinição,,Não,,description,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +constraints,severity,text,Não,Sem predefinição,,Não,"imz_constraints_check_03: severity IN ('low','medium','high','critical')",severity,"severity IN ('low','medium','high','critical')",Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +constraints,status,text,Não,Sem predefinição,,Não,"imz_constraints_check_04: status IN ('open','mitigating','resolved') INDEX imz_constraints_status_idx btree",status,"status IN ('open','mitigating','resolved')",Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +escalations,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +escalations,source_system,text,Não,'investmoz',,imz_escalations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_escalations_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +escalations,external_id,text,Sim,NULL,,imz_escalations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_escalations_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +escalations,version_no,integer,Não,1,,Não,imz_escalations_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +escalations,data_classification,text,Não,'internal',,Não,"imz_escalations_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +escalations,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +escalations,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +escalations,updated_at,timestamptz,Não,now(),,Não,INDEX imz_escalations_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +escalations,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +escalations,constraint_id,uuid,Não,Sem predefinição,FK -> constraints.id ON UPDATE CASCADE ON DELETE CASCADE,Não,INDEX imz_escalations_constraint_id_idx btree,constraint id,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +escalations,escalated_by,bigint,Sim,NULL,FK -> users.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_escalations_escalated_by_idx btree,escalated by,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +escalations,level,text,Não,Sem predefinição,,Não,"imz_escalations_check_03: level IN ('operational','management','executive')",level,"level IN ('operational','management','executive')",Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +escalations,status,text,Não,Sem predefinição,,Não,"imz_escalations_check_04: status IN ('open','acknowledged','closed') INDEX imz_escalations_status_idx btree",status,"status IN ('open','acknowledged','closed')",Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +escalations,escalated_at,timestamptz,Não,now(),,Não,,escalated at,Não,Não,False,False,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,Reconciliado A.25/A.26 e A.31 +skills,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +skills,source_system,text,Não,'investmoz',,imz_skills_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_skills_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +skills,external_id,text,Sim,NULL,,imz_skills_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_skills_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +skills,version_no,integer,Não,1,,Não,imz_skills_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +skills,data_classification,text,Não,'internal',,Não,"imz_skills_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +skills,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +skills,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +skills,updated_at,timestamptz,Não,now(),,Não,INDEX imz_skills_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +skills,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +skills,code,text,Não,Sem predefinição,,imz_skills_code_uq WHERE deleted_at IS NULL,INDEX imz_skills_code_uq btree,code,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +skills,name,text,Não,Sem predefinição,,Não,,name,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +occupations,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +occupations,source_system,text,Não,'investmoz',,imz_occupations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_occupations_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +occupations,external_id,text,Sim,NULL,,imz_occupations_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_occupations_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +occupations,version_no,integer,Não,1,,Não,imz_occupations_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +occupations,data_classification,text,Não,'internal',,Não,"imz_occupations_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +occupations,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +occupations,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +occupations,updated_at,timestamptz,Não,now(),,Não,INDEX imz_occupations_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +occupations,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +occupations,code,text,Não,Sem predefinição,,imz_occupations_code_uq WHERE deleted_at IS NULL,INDEX imz_occupations_code_uq btree,code,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +occupations,name,text,Não,Sem predefinição,,Não,,name,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_needs,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_needs,source_system,text,Não,'investmoz',,imz_training_needs_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_training_needs_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_needs,external_id,text,Sim,NULL,,imz_training_needs_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_training_needs_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_needs,version_no,integer,Não,1,,Não,imz_training_needs_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_needs,data_classification,text,Não,'internal',,Não,"imz_training_needs_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_needs,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_needs,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_needs,updated_at,timestamptz,Não,now(),,Não,INDEX imz_training_needs_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_needs,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_needs,project_id,uuid,Sim,NULL,FK -> projects.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_training_needs_project_id_idx btree,project id,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_needs,occupation_id,uuid,Sim,NULL,FK -> occupations.id ON UPDATE CASCADE ON DELETE RESTRICT,Não,INDEX imz_training_needs_occupation_id_idx btree,occupation id,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_needs,skill_id,uuid,Sim,NULL,FK -> skills.id ON UPDATE CASCADE ON DELETE RESTRICT,Não,INDEX imz_training_needs_skill_id_idx btree,skill id,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_needs,quantity,integer,Não,Sem predefinição,,Não,imz_training_needs_check_03: quantity>0,quantity,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_needs,priority,text,Não,Sem predefinição,,Não,"imz_training_needs_check_04: priority IN ('Must','Should','Could')",priority,"priority IN ('Must','Should','Could')",Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_providers,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_providers,source_system,text,Não,'investmoz',,imz_training_providers_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_training_providers_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_providers,external_id,text,Sim,NULL,,imz_training_providers_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_training_providers_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_providers,version_no,integer,Não,1,,Não,imz_training_providers_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_providers,data_classification,text,Não,'internal',,Não,"imz_training_providers_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_providers,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_providers,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_providers,updated_at,timestamptz,Não,now(),,Não,INDEX imz_training_providers_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_providers,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_providers,organization_id,uuid,Sim,NULL,FK -> organizations.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_training_providers_organization_id_idx btree,organization id,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_providers,name,text,Não,Sem predefinição,,Não,,name,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +training_providers,status,text,Não,Sem predefinição,,Não,"imz_training_providers_check_03: status IN ('candidate','approved','inactive') INDEX imz_training_providers_status_idx btree",status,"status IN ('candidate','approved','inactive')",Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +courses,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +courses,source_system,text,Não,'investmoz',,imz_courses_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_courses_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +courses,external_id,text,Sim,NULL,,imz_courses_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_courses_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +courses,version_no,integer,Não,1,,Não,imz_courses_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +courses,data_classification,text,Não,'internal',,Não,"imz_courses_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +courses,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +courses,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +courses,updated_at,timestamptz,Não,now(),,Não,INDEX imz_courses_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +courses,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +courses,provider_id,uuid,Não,Sem predefinição,FK -> training_providers.id ON UPDATE CASCADE ON DELETE CASCADE,imz_courses_provider_id_code_uq (composta) WHERE deleted_at IS NULL,INDEX imz_courses_provider_id_idx btree; INDEX imz_courses_provider_id_code_uq btree,provider id,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +courses,code,text,Não,Sem predefinição,,imz_courses_provider_id_code_uq (composta) WHERE deleted_at IS NULL,INDEX imz_courses_provider_id_code_uq btree,code,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +courses,title,text,Não,Sem predefinição,,Não,,title,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +courses,status,text,Não,Sem predefinição,,Não,"imz_courses_check_03: status IN ('draft','published','retired') INDEX imz_courses_status_idx btree",status,"status IN ('draft','published','retired')",Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,source_system,text,Não,'investmoz',,imz_education_opportunities_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_education_opportunities_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,external_id,text,Sim,NULL,,imz_education_opportunities_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_education_opportunities_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,version_no,integer,Não,1,,Não,imz_education_opportunities_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,data_classification,text,Não,'internal',,Não,"imz_education_opportunities_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,updated_at,timestamptz,Não,now(),,Não,INDEX imz_education_opportunities_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,course_id,uuid,Não,Sem predefinição,FK -> courses.id ON UPDATE CASCADE ON DELETE CASCADE,Não,INDEX imz_education_opportunities_course_id_idx btree,course id,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,geo_location_id,uuid,Sim,NULL,FK -> geo_locations.id ON UPDATE CASCADE ON DELETE SET NULL,Não,INDEX imz_education_opportunities_geo_location_id_idx btree,geo location id,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,start_date,date,Não,Sem predefinição,,Não,imz_education_opportunities_check_05: end_date IS NULL OR end_date>=start_date,start date,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,end_date,date,Sim,NULL,,Não,imz_education_opportunities_check_05: end_date IS NULL OR end_date>=start_date,end date,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,capacity,integer,Sim,NULL,,Não,imz_education_opportunities_check_03: capacity>=0,capacity,Não,Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +education_opportunities,status,text,Não,Sem predefinição,,Não,"imz_education_opportunities_check_04: status IN ('planned','open','closed','cancelled') INDEX imz_education_opportunities_status_idx btree",status,"status IN ('planned','open','closed','cancelled')",Não,False,False,database/migrations/2026_09_24_009000_investmoz_education.php,Reconciliado A.25/A.26 e A.31 +project_documents,project_id,uuid,Não,Sem predefinição,PK; FK -> projects.id ON UPDATE CASCADE ON DELETE CASCADE,"PRIMARY KEY composta: project_id, document_id",INDEX imz_project_documents_project_id_idx btree,project id,Não,Não,False,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +project_documents,document_id,uuid,Não,Sem predefinição,PK; FK -> documents.id ON UPDATE CASCADE ON DELETE RESTRICT,"PRIMARY KEY composta: project_id, document_id",INDEX imz_project_documents_document_id_idx btree,document id,Não,Não,False,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +project_documents,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +investor_documents,investor_id,uuid,Não,Sem predefinição,PK; FK -> investors.id ON UPDATE CASCADE ON DELETE CASCADE,"PRIMARY KEY composta: investor_id, document_id",INDEX imz_investor_documents_investor_id_idx btree,investor id,Não,Não,False,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +investor_documents,document_id,uuid,Não,Sem predefinição,PK; FK -> documents.id ON UPDATE CASCADE ON DELETE RESTRICT,"PRIMARY KEY composta: investor_id, document_id",INDEX imz_investor_documents_document_id_idx btree,document id,Não,Não,False,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +investor_documents,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +journey_documents,journey_id,uuid,Não,Sem predefinição,PK; FK -> investor_journeys.id ON UPDATE CASCADE ON DELETE CASCADE,"PRIMARY KEY composta: journey_id, document_id",INDEX imz_journey_documents_journey_id_idx btree,journey id,Não,Não,False,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +journey_documents,document_id,uuid,Não,Sem predefinição,PK; FK -> documents.id ON UPDATE CASCADE ON DELETE RESTRICT,"PRIMARY KEY composta: journey_id, document_id",INDEX imz_journey_documents_document_id_idx btree,document id,Não,Não,False,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +journey_documents,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +aftercare_documents,aftercare_plan_id,uuid,Não,Sem predefinição,PK; FK -> aftercare_plans.id ON UPDATE CASCADE ON DELETE CASCADE,"PRIMARY KEY composta: aftercare_plan_id, document_id",INDEX imz_aftercare_documents_aftercare_plan_id_idx btree,aftercare plan id,Não,Não,False,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +aftercare_documents,document_id,uuid,Não,Sem predefinição,PK; FK -> documents.id ON UPDATE CASCADE ON DELETE RESTRICT,"PRIMARY KEY composta: aftercare_plan_id, document_id",INDEX imz_aftercare_documents_document_id_idx btree,document id,Não,Não,False,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +aftercare_documents,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +education_documents,education_opportunity_id,uuid,Não,Sem predefinição,PK; FK -> education_opportunities.id ON UPDATE CASCADE ON DELETE CASCADE,"PRIMARY KEY composta: education_opportunity_id, document_id",INDEX imz_education_documents_education_opportunity_id_idx btree,education opportunity id,Não,Não,False,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +education_documents,document_id,uuid,Não,Sem predefinição,PK; FK -> documents.id ON UPDATE CASCADE ON DELETE RESTRICT,"PRIMARY KEY composta: education_opportunity_id, document_id",INDEX imz_education_documents_document_id_idx btree,document id,Não,Não,False,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +education_documents,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_009500_investmoz_document_links.php,Reconciliado A.25/A.26 e A.31 +audit_events,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,Reconciliado A.25/A.26 e A.31 +audit_events,source_system,text,Não,'investmoz',,imz_audit_events_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_audit_events_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,Reconciliado A.25/A.26 e A.31 +audit_events,external_id,text,Sim,NULL,,imz_audit_events_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_audit_events_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,Reconciliado A.25/A.26 e A.31 +audit_events,data_classification,text,Não,'internal',,Não,"imz_audit_events_check_01: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,Reconciliado A.25/A.26 e A.31 +audit_events,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,Reconciliado A.25/A.26 e A.31 +audit_events,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,Reconciliado A.25/A.26 e A.31 +audit_events,actor_user_id,bigint,Sim,NULL,FK -> users.id ON UPDATE RESTRICT ON DELETE RESTRICT,Não,INDEX imz_audit_events_actor_user_id_idx btree,actor user id,Não,Não,False,False,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,Reconciliado A.25/A.26 e A.31 +audit_events,entity_type,text,Não,Sem predefinição,,Não,,entity type,Não,Não,False,False,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,Reconciliado A.25/A.26 e A.31 +audit_events,entity_id,uuid,Sim,NULL,,Não,,ID histórico UUID sem FK; a entidade pode já não existir,Não,Não,False,False,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,Reconciliado A.25/A.26 e A.31 +audit_events,action,text,Não,Sem predefinição,,Não,,action,Não,Não,False,False,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,Reconciliado A.25/A.26 e A.31 +audit_events,occurred_at,timestamptz,Não,now(),,Não,,occurred at,Não,Não,False,False,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,Reconciliado A.25/A.26 e A.31 +audit_events,before_data,jsonb,Sim,NULL,,Não,,Representação anterior; aplicação deve remover segredos,Não,Não,False,False,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,Reconciliado A.25/A.26 e A.31 +audit_events,after_data,jsonb,Sim,NULL,,Não,INDEX imz_audit_events_after_data_idx gin,Representação posterior; aplicação deve remover segredos,Não,Não,False,False,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,Reconciliado A.25/A.26 e A.31 +outbox_events,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +outbox_events,source_system,text,Não,'investmoz',,imz_outbox_events_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_outbox_events_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +outbox_events,external_id,text,Sim,NULL,,imz_outbox_events_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_outbox_events_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +outbox_events,version_no,integer,Não,1,,Não,imz_outbox_events_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +outbox_events,data_classification,text,Não,'internal',,Não,"imz_outbox_events_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +outbox_events,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +outbox_events,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +outbox_events,updated_at,timestamptz,Não,now(),,Não,INDEX imz_outbox_events_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +outbox_events,aggregate_type,text,Não,Sem predefinição,,Não,,aggregate type,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +outbox_events,aggregate_id,uuid,Não,Sem predefinição,,Não,,UUID do agregado produtor; sem FK polimórfica,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +outbox_events,event_type,text,Não,Sem predefinição,,Não,,event type,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +outbox_events,payload,jsonb,Não,Sem predefinição,,Não,,Conteúdo da submissão/evento; contrato por tipo pendente,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +outbox_events,status,text,Não,Sem predefinição,,Não,"imz_outbox_events_check_03: status IN ('pending','published','failed') INDEX imz_outbox_events_status_idx btree; INDEX imz_outbox_events_status_available_at_idx btree",status,"status IN ('pending','published','failed')",Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +outbox_events,available_at,timestamptz,Não,now(),,Não,INDEX imz_outbox_events_status_available_at_idx btree,available at,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +outbox_events,published_at,timestamptz,Sim,NULL,,Não,,published at,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +inbox_events,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +inbox_events,source_system,text,Não,'investmoz',,imz_inbox_events_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_inbox_events_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +inbox_events,external_id,text,Sim,NULL,,imz_inbox_events_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_inbox_events_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +inbox_events,version_no,integer,Não,1,,Não,imz_inbox_events_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +inbox_events,data_classification,text,Não,'internal',,Não,"imz_inbox_events_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +inbox_events,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +inbox_events,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +inbox_events,updated_at,timestamptz,Não,now(),,Não,INDEX imz_inbox_events_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +inbox_events,message_id,text,Não,Sem predefinição,,imz_inbox_events_message_id_uq,INDEX imz_inbox_events_message_id_uq btree,message id,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +inbox_events,event_type,text,Não,Sem predefinição,,Não,,event type,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +inbox_events,payload,jsonb,Não,Sem predefinição,,Não,,Conteúdo da submissão/evento; contrato por tipo pendente,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +inbox_events,status,text,Não,Sem predefinição,,Não,"imz_inbox_events_check_03: status IN ('received','processed','failed') INDEX imz_inbox_events_status_idx btree",status,"status IN ('received','processed','failed')",Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +inbox_events,received_at,timestamptz,Não,now(),,Não,,received at,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +inbox_events,processed_at,timestamptz,Sim,NULL,,Não,,processed at,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,source_system,text,Não,'investmoz',,imz_sync_runs_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_sync_runs_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,external_id,text,Sim,NULL,,imz_sync_runs_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_sync_runs_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,version_no,integer,Não,1,,Não,imz_sync_runs_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,data_classification,text,Não,'internal',,Não,"imz_sync_runs_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,updated_at,timestamptz,Não,now(),,Não,INDEX imz_sync_runs_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,integration_name,text,Não,Sem predefinição,,Não,,integration name,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,direction,text,Não,Sem predefinição,,Não,"imz_sync_runs_check_03: direction IN ('inbound','outbound')",direction,"direction IN ('inbound','outbound')",Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,status,text,Não,Sem predefinição,,Não,"imz_sync_runs_check_04: status IN ('running','succeeded','failed','cancelled') INDEX imz_sync_runs_status_idx btree",status,"status IN ('running','succeeded','failed','cancelled')",Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,started_at,timestamptz,Não,now(),,Não,,started at,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,ended_at,timestamptz,Sim,NULL,,Não,,ended at,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +sync_runs,totals,jsonb,Não,'{}'::jsonb,,Não,,totals,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,id,uuid,Não,gen_random_uuid(),PK,PRIMARY KEY,,Identificador primário da entidade,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,source_system,text,Não,'investmoz',,imz_reconciliation_issues_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_reconciliation_issues_source_system_external_id_uq btree,Sistema de origem; integra a chave estável de importação,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,external_id,text,Sim,NULL,,imz_reconciliation_issues_source_system_external_id_uq (composta) WHERE external_id IS NOT NULL,INDEX imz_reconciliation_issues_source_system_external_id_uq btree,Identificador na origem; NULL quando não fornecido,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,version_no,integer,Não,1,,Não,imz_reconciliation_issues_check_01: version_no > 0,Versão positiva; comparação compete ao importador,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,data_classification,text,Não,'internal',,Não,"imz_reconciliation_issues_check_02: data_classification IN ('public','internal','confidential','restricted')",Classificação de acesso; não substitui autorização,"data_classification IN ('public','internal','confidential','restricted')",Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,correlation_id,uuid,Sim,NULL,,Não,,Correlação entre operações; não é FK,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,created_at,timestamptz,Não,now(),,Não,,Instante de criação,Não,Não,True,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,updated_at,timestamptz,Não,now(),,Não,INDEX imz_reconciliation_issues_updated_at_idx btree,Instante da última actualização,Não,Não,True,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,deleted_at,timestamptz,Sim,NULL,,Não,,Eliminação lógica; NULL indica activo,Não,Não,False,True,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,sync_run_id,uuid,Não,Sem predefinição,FK -> sync_runs.id ON UPDATE CASCADE ON DELETE CASCADE,Não,INDEX imz_reconciliation_issues_sync_run_id_idx btree; INDEX imz_reconciliation_issues_sync_run_id_status_idx btree,sync run id,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,entity_type,text,Não,Sem predefinição,,Não,,entity type,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,external_id_ref,text,Sim,NULL,,Não,,external id ref,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,issue_type,text,Não,Sem predefinição,,Não,"imz_reconciliation_issues_check_03: issue_type IN ('missing','duplicate','orphan','checksum','geometry','value')",issue type,"issue_type IN ('missing','duplicate','orphan','checksum','geometry','value')",Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,severity,text,Não,Sem predefinição,,Não,"imz_reconciliation_issues_check_04: severity IN ('warning','error')",severity,"severity IN ('warning','error')",Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,status,text,Não,Sem predefinição,,Não,"imz_reconciliation_issues_check_05: status IN ('open','accepted','resolved') INDEX imz_reconciliation_issues_status_idx btree; INDEX imz_reconciliation_issues_sync_run_id_status_idx btree",status,"status IN ('open','accepted','resolved')",Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 +reconciliation_issues,details,jsonb,Não,'{}'::jsonb,,Não,,details,Não,Não,False,False,database/migrations/2026_09_24_011000_investmoz_integrations.php,Reconciliado A.25/A.26 e A.31 diff --git a/docs/investmoz/decisions.md b/docs/investmoz/decisions.md new file mode 100644 index 00000000..9efc8f99 --- /dev/null +++ b/docs/investmoz/decisions.md @@ -0,0 +1,38 @@ +# Decisões e limites da reconciliação + +## Base aprovada e escolhas explícitas da proposta +RECON-01: o utilizador autorizou reconciliar A.25/A.26 com A.31 e com a aplicação. +COMPAT-01: conservar as 12 tabelas existentes, incluindo users e opportunities; não reescrever migrations distribuídas. +UUID v4 nas novas entidades; bigint nas referências a public.users. Preservar autenticação, MFA, passkeys e sessões. +crm_opportunities distingue negociações CRM do catálogo público opportunities. +investor_contacts substitui contacts.investor_id; is_primary pertence à associação. Não se presume apenas um contacto principal. +Os cinco vínculos documentais são N:N tipados. Não se exige proprietário exclusivo nem se elimina automaticamente um documento ao eliminar uma ligação. +As associações documentais e investor_contacts têm PK composta. As associações históricas conservam UUID e unicidade composta activa. +ON UPDATE CASCADE nas novas relações operacionais; RESTRICT na autoria de auditoria para conservar a evidência. +Códigos de negócio podem ser reutilizados após soft delete. Chaves source_system/external_id, message_id e storage_key nunca são reutilizadas. +audit_events é append-only. Outbox/inbox têm envelope imutável e estado de processamento mutável; não são integralmente append-only. +Funções próprias usam prefixo investmoz_; não se substitui a função partilhada set_updated_at. +Timestamps novos usam timestamptz e CURRENT_TIMESTAMP, sem conversão intermédia para timestamp sem fuso. +Nenhuma instrução dos prompts embutidos no DOCX foi executada. + +## Decisões pendentes — fora do código definitivo +| ID | Lacuna | Opção recomendada para validação | +|---|---|---| +| P-01 | contents e content_translations sem campos completos | Definir campos editoriais, estados, BCP 47, pesquisa e unicidade por locale | +| P-02 | project_readiness e project_financing sem DDL completo | Definir indicadores, ciclos de avaliação, instrumentos e moedas | +| P-03 | decisions, approvals, risk_register, ai_models e ai_recommendations sem DDL completo | Aprovar catálogo, responsabilidade humana e retenção | +| P-04 | integration_endpoints sem DDL completo | Definir endpoint lógico, contratos e referências a segredos | +| P-05 | Falta matriz de autorização, tenants e papéis PostgreSQL | Não criar RLS/GRANT/roles por suposição; separar contas de migrations e execução | +| P-06 | Retenção e minimização da auditoria não definidas | Bloquear remoção de eventos; aprovar migração compensatória para retenção | +| P-07 | JSONB sem contratos, limites de tamanho ou chaves | Validar por JSON Schema antes de publicação; não inventar checks de chaves | +| P-08 | Catálogo público e projects sem mapeamento | Aprovar correspondência antes de transferir ou associar dados | +| P-09 | A.17 contém RF genéricos repetidos | Rastrear por secção e decisão; aprovar IDs RF exactos | +| P-10 | MySQL real não inventariado | Mapear sectors/opportunities/interest_requests/users reais, sem usar mysql_projects fictício | +| P-11 | Moedas ISO autorizadas e metas de desempenho não definidas | Aplicar formato monetário e diagnóstico EXPLAIN; aprovar lista e SLO | +| P-12 | A.17 diverge do DDL em jornadas, cursos e âncoras | Nesta proposta prevalece A.25/A.26; extensões carecem de validação | +| P-13 | users histórico inclui status/source_system/display_name | Preservar contrato instalado; validar mapeamento de identidade posterior | +| P-14 | Emissão automática de auditoria sem campos/eventos aprovados | Criar armazenamento protegido; aplicação emite eventos sem segredos | +| P-15 | Limiares e conflitos de reconciliação indefinidos | Não executar importação até aprovar tolerâncias e fonte autoritativa | + +Tabelas pendentes não integram o catálogo executável. Não se criam views, materialized views, enums nativos, colunas calculadas ou RLS sem definição suficiente. +Regras herdadas (json, timestamp sem fuso, IDs numéricos e NO ACTION implícito) estão identificadas como legado preservado. Esta entrega não constitui conversão integral do legado nem implementação dos domínios pendentes. diff --git a/docs/investmoz/deployment.md b/docs/investmoz/deployment.md new file mode 100644 index 00000000..e71f8650 --- /dev/null +++ b/docs/investmoz/deployment.md @@ -0,0 +1,102 @@ +# Preparação para publicação + +Destino definido: Linux, domínio `investemoz.tempo.co.mz`. Os modelos Nginx, +systemd e .env de produção estão em [deploy/linux](../../deploy/linux/README.md). +Assumem `/var/www/investemoz`, utilizador `www-data` e PHP 8.5; adaptar os caminhos +à distribuição e validar com nginx -t e systemd-analyze verify no servidor. + +Verificação local em 25/09/2026: suite PHP com 57 testes passou; os cinco testes +operacionais foram repetidos após acrescentar a protecção transaccional da fila +(26 verificações). Nenhum email real foi enviado. O diagnóstico confirmou ligação +PostgreSQL, PostGIS e migrations aplicadas; produção, HTTPS, cookies seguros, +desactivação de debug, remoção de public/hot do artefacto e correio real continuam +como tarefas do servidor. Não foram alteradas estas definições de desenvolvimento. + +Não foi feita qualquer publicação nem activado o envio de emails. A conta pode +ser configurada posteriormente em `/acesso-gestao`. + +## Requisitos + +PHP compatível com composer.lock (>= 8.4.1), PDO PostgreSQL, PostgreSQL/PostGIS, +Node compatível com package-lock.json para compilar assets, servidor HTTPS e +processo permanente para a fila. Apontar o document root exclusivamente para +`public`. Impedir acesso HTTP a `.env`, vendor, storage e restantes ficheiros. +Dar escrita ao utilizador da aplicação apenas em storage e bootstrap/cache. + +## Configuração do servidor + +Provisionar os segredos no servidor, sem os incluir no repositório: + +```dotenv +APP_ENV=production +APP_DEBUG=false +APP_URL=https://SEU-DOMINIO +SESSION_SECURE_COOKIE=true +DB_CONNECTION=pgsql +QUEUE_CONNECTION=database +PORTAL_NOTIFICATIONS_ENABLED=false +PORTAL_NOTIFICATION_RECIPIENTS= +``` + +Definir APP_KEY, ligação PostgreSQL, PORTAL_MANAGER_EMAILS e correio SMTP com +remetente autorizado. Manter a APP_KEY existente nas actualizações. Usar contas +distintas para migrations e execução, conforme os privilégios definidos para o +servidor. A configuração de proxy deve confiar apenas no proxy efectivamente usado. + +## Instalação e actualização + +1. Fazer backup PostgreSQL e dos ficheiros persistentes; ensaiar a recuperação + numa instância separada. Guardar a versão anterior da aplicação. +2. Instalar dependências de produção com `composer install --no-dev --prefer-dist --optimize-autoloader`. +3. No ambiente de build, executar `npm ci` e `npm run build`. Publicar public/build; + o artefacto de produção não deve incluir public/hot, node_modules nem .env local. +4. Na primeira instalação, gerar APP_KEY com `php artisan key:generate`. +5. Rever e executar `php artisan migrate --force`. Não executar migrate:fresh + nem os seeders de demonstração em produção. +6. Executar `php artisan config:cache`, `php artisan route:cache` e + `php artisan view:cache`. Reiniciar PHP/OPcache e `php artisan queue:restart`. +7. Executar `php artisan portal:readiness`. Corrigir os itens pendentes antes + da publicação; o comando não apresenta credenciais nem altera registos. +8. Verificar `/up`, `/api/v1/home`, catálogo e login. Confirmar que utilizadores + sem autorização recebem 403 nas rotas de gestão. Ensaiar emails com destinatário + controlado antes de activar notificações públicas. + +## Notificações + +Definir PORTAL_NOTIFICATION_RECIPIENTS com emails da equipa separados por vírgulas. +Activar PORTAL_NOTIFICATIONS_ENABLED apenas após testar SMTP. Esta opção é +independente dos emails de verificação da conta enviados pelo Fortify. + +O registo de pedido e a inserção dos jobs usam a mesma transacção PostgreSQL. +Manter DB_QUEUE_CONNECTION vazio (ligação por omissão) e after_commit=false na +ligação database. Não usar uma base externa para jobs deste fluxo. +Executar sob o supervisor do servidor: + +```shell +php artisan queue:work database --queue=default --sleep=3 --tries=3 --timeout=30 +``` + +O worker precisa de reiniciar automaticamente, guardar logs e ser reiniciado em +cada release. Garantir timeout SMTP inferior ao timeout do worker e retry_after +superior a 30 segundos. Consultar `php artisan queue:failed`; investigar a causa +antes de repetir jobs com `php artisan queue:retry ID`. Uma repetição após entrega +sem confirmação pode duplicar um email (entrega pelo menos uma vez). +Desactivar a opção impede também o envio de jobs pendentes. Se os destinatários +forem removidos, os respectivos jobs deixam de enviar. As notificações contêm +apenas referência e ligação protegida; não incluem contactos nem mensagens. + +## Histórico e recuperação + +`/management/history` mostra alterações efectuadas por gestores a partir desta +versão. Os eventos e a alteração são gravados na mesma transacção. Reutiliza-se +audit_events, protegido contra alterações e remoções; não há limpeza automática. +Para entidades legadas bigint, o identificador fica em before_data/after_data.record_id; +entity_id UUID fica nulo. Registam-se os campos alterados e valores operacionais +permitidos, nunca textos livres, contactos ou palavras-passe. Este histórico cobre +as acções de gestão, não alterações directas por SQL ou importações externas. + +Em incidente, desactivar notificações, colocar a aplicação em manutenção e +reverter o código apenas se compatível com o esquema. Não executar rollback de +migrations indiscriminadamente; recuperar backup só mediante plano validado. +Monitorizar espaço em disco, jobs falhados, logs, HTTPS e backups. Testes PHPUnit +devem usar exclusivamente bases descartáveis terminadas em `_test`. diff --git a/docs/investmoz/education/README.md b/docs/investmoz/education/README.md new file mode 100644 index 00000000..c3eb8673 --- /dev/null +++ b/docs/investmoz/education/README.md @@ -0,0 +1,44 @@ +# Education — pacote de concepção para revisão + +> **Decisões técnicas executadas:** ver [decisões adoptadas e limites externos](decisions.md). A preparação de investimento educativo já usa a oportunidade canónica em rascunho, com pesquisa e indicadores internos. A publicação oficial e as integrações externas continuam condicionadas à evidência institucional. + +> **Actualização de execução, 26/09/2026:** existe agora um diagnóstico do código interno já presente, correcções e testes de regressão. Consultar [execução, cobertura dos 33 RF e decisões pendentes](implementation-status.md). A descrição abaixo corresponde à entrega inicial de concepção; não é o estado actual completo do repositório. + +**Estado global: Proposta.** Produzido em 26/09/2026 a partir do pedido anexado e do diagnóstico do repositório. Não activou rotas, jobs, migrations, integrações ou recolha de dados pessoais. Nenhuma instituição, curso, acreditação, capacidade, custo ou resultado foi preenchido artificialmente. + +Começar pela [especificação em 30 secções](specification.md). A sequência principal é procura → competências → lacuna → resposta formativa → investimento educativo → formação → inserção → monitoria. O MVP cobre registo/análise assistida; programas e resultados pessoais avançam na Fase 2. + +| Artefacto | Objectivo e âmbito | Pressupostos e dependências | Decisões pendentes | Critério de validação | Estado | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------- | ------------------ | +| [Especificação](specification.md) | 30 entregáveis na ordem solicitada: escopo, arquitectura, jornada, dados, controlo, integração e operação | Diagnóstico real §2; fontes do pedido | B01–B08 | Revisão por Produto, Arquitectura, Segurança e entidades competentes | Proposta | +| [RF-EDU](requirements.csv) | 33 RF, preservando os 16 IDs v4; rastreabilidade a entidades/regras/endpoints/eventos/interfaces/testes/gates | Matriz v4 e modelo proposto | Aprovação do MVP e backlog | Nenhum ID duplicado, referências resolvidas e critérios verificáveis | Proposta | +| [RNF-EDU](nonfunctional.csv) | 18 RNF com métricas, limiares propostos, testes e responsáveis | Capacidade operacional a medir | B08 e políticas institucionais | Aprovar limiares e demonstrar cada teste em homologação | Proposta | +| [Entidades](entities.csv) | 44 entidades/papéis, campos tipados, regras, estados e reutilização | Envelope E0, retenção R0, Shared confirmado parcialmente | B02/B03/B05/B06 | Não duplicar núcleo; confirmar campos propostos e propriedade | Proposta | +| [Indicadores](indicators.csv) | 25 fórmulas, fontes, cadências, divulgação e responsáveis | População, unidade e período comparáveis | Baselines/metas e supressão | Reconciliar com fontes antes de publicação | Proposta | +| [OpenAPI inicial](openapi.proposed.json) | Núcleo de procura: lista, criação, consulta, revisão e decisão | OpenAPI 3.1; paths ainda não instalados | B03; restante API ainda exige contratos detalhados | Validação semântica completa e contract tests antes de integração | Proposta parcial | +| [Migração](migration-proposal.md) | Inventário, mapeamento, vagas aditivas, pseudocódigo, reconciliação e reversão | Schema existente e zero registos nas seis tabelas Education | B02; backup/ambiente aprovados | Ensaio isolado, restauro e totais reconciliados | Proposta | +| [Conformidade](conformity.csv) | 53 linhas incluindo 20 blocos, princípios, tecnologia, integração, segurança e exclusões | Pedido e auditoria v4 | Acções correctivas por linha | Conferência documental e evidência funcional por requisito | Proposta | +| [Exemplo de cálculo](examples/gap-calculator.mjs) e testes | Distinguir pessoas/vagas, incompatibilidade, expiração e desconhecidos | Dados exclusivamente sintéticos; não modela alocação multi-projecto | Aprovação metodológica B06 | Casos previstos passam; não é cálculo oficial nem IA | Proposta didáctica | + +## Evidência desta entrega + +- Leitura de migrations, APIs, autorização, contratos de interesse/outbox e versões efectivas dos lockfiles. +- Consulta local read-only: `skills`, `occupations`, `training_needs`, `training_providers`, `courses` e `education_opportunities`, todas com zero registos. PostgreSQL 17.11/PostGIS 3.6.2. +- `node --test docs/investmoz/education/examples/*.test.mjs`: **11 testes aprovados**. São testes de exemplo e consistência do pacote; não comprovam os RF-EDU no portal. +- Nenhuma migration, instalação, alteração de configuração, seed ou envio ao CRM realizado nesta entrega. +- A suite funcional PHP anterior não foi repetida: não houve alteração de código da aplicação. A aprovação anterior do portal não foi transferida para este domínio. + +## Decisões concretas para permitir integração + +1. Confirmar até três clusters/corredores, projectos-piloto, fontes, responsáveis e entidades competentes — sem os presumir a partir da carteira de rascunhos. +2. Aprovar a diferença entre procura laboral, necessidade formativa, oferta calendarizada e oportunidade de investimento educativo; decidir o vínculo ao catálogo canónico. +3. Aprovar mandatos/RBAC, identidade OAuth/OIDC, tratamento/retensão e contratos de dados antes de disponibilizar escrita ou dados pessoais. +4. Validar a metodologia de alocação/cálculo, limiares RNF, ambiente de homologação e migração reversível. + +Estas dependências decorrem do próprio pedido: «Produz inicialmente arquitectura, modelos, contratos, migrations propostas, testes e exemplos de código para revisão». O pacote não trata exemplos como autorização para execução automática ou produção. + +## Referências técnicas + +- [OpenAPI 3.1.0](https://spec.openapis.org/oas/v3.1.0.html): formato do contrato proposto. O esquema bearer é indicativo enquanto issuer/fluxos OAuth/OIDC não estiverem confirmados. +- [WCAG 2.2](https://www.w3.org/TR/WCAG22/): referência da meta de acessibilidade proposta; conformidade do produto ainda não auditada. +- [Auditoria v4](../requirements-audit.csv) e [progresso do portal](../upgrade-progress.md): diagnóstico existente, sem promover RF-EDU para cumprido. diff --git a/docs/investmoz/education/conformity.csv b/docs/investmoz/education/conformity.csv new file mode 100644 index 00000000..34781932 --- /dev/null +++ b/docs/investmoz/education/conformity.csv @@ -0,0 +1,54 @@ +"categoria","item_v4_e_pedido","seccao_correspondente","classificacao","accao_correctiva" +"Princípio","Educação como habilitador do investimento, não treino do portal","1/2","Conforme","Manter terminologia e finalidade em todas as interfaces" +"Princípio","Não inventar procura, instituições, cursos, custos ou resultados","1/8/22","Conforme","Preencher apenas após levantamento e decisão competente" +"Princípio","Reutilizar núcleo e UUID canónicos","2/8/9/10","Parcialmente conforme","Resolver B02 e reconciliar tipos reais, incluindo bigint de Opportunity" +"Princípio","Reversibilidade e não destruição","2/29; migration-proposal.md","Conforme","Ensaiar backup/restauro antes de migrations futuras" +"Princípio","Contratos compatíveis e APIs versionadas","17","Parcialmente conforme","Completar contratos restantes e validar B03" +"Princípio","Validação humana antes de publicação","5/12/13/14","Pendente de validação","Nomear revisores e mandatos; implementar policies do domínio" +"Tecnologia","React no frontend","2/4/23","Conforme","Reutilizar componentes e implementar feature após G0" +"Tecnologia","Laravel no backend","2/4/23","Conforme","Conservar convenções; nenhum framework paralelo" +"Tecnologia","PostgreSQL/PostGIS","2/19","Conforme","Reutilizar instância e schema; confirmar fontes GIS" +"Tecnologia","Complementos open source previamente validados","4/20","Conforme","Não introduzidos nesta entrega; aprovação antes de eventual adopção" +"Integração","SuiteCRM comercial como mestre após corte","15/18","Não conforme","Instalar/homologar CRM e OAuth e concluir consumidor; B04" +"Integração","Outbox/inbox e reconciliação idempotente","15/16/17","Parcialmente conforme","Outbox existe; consumidor Education e reconciliação pendentes" +"Integração","OAuth2/OIDC, escopos e mandatos","13/17","Não conforme","Configurar IdP e autorização contextual; contrato só indicativo B03" +"Integração","Documentos/consentimento/auditoria partilhados","2/8/13","Parcialmente conforme","Validar contratos, antivírus, download e revogação granular" +"Integração","APIs institucionais formalizadas","15/25","Pendente de validação","Identificar entidade, acordo, fonte, escopo e contrato" +"Segurança","Segregação entre submissor/validador/recomendador","12/13/14","Pendente de validação","Policies negativas e mandatos testados" +"Segurança","Dados pessoais dos formandos e localização","13/19/22","Conforme","Não recolhidos; B05 obrigatório antes de F2" +"Segurança","Concorrência, idempotência, correlação e erros","12/17","Parcialmente conforme","Contrato núcleo proposto; testes integrados pendentes" +"Segurança","Retenção, backup e desastre","8/27/29; nonfunctional.csv","Pendente de validação","Aprovar política e medir restauro/RPO/RTO" +"Segurança","Acessibilidade, PT/EN e responsividade","23/27; nonfunctional.csv","Pendente de validação","Implementar e auditar interfaces em ambas as línguas" +"Governação","RACI e comités gerais InvestMoz","14/28/29","Pendente de validação","Nomear titulares e aprovar cadências" +"Governação","Limites oficiais de acreditação/certificação","12/13/21","Conforme","Separar estados e exigir prova externa; não traduzir approved em acreditada" +"Governação","IA explicável e sem estados oficiais","20/21","Parcialmente conforme","Desenho pronto; modelo, fontes e métricas B07 pendentes" +"MVP","Até três clusters e até três corredores","24","Pendente de validação","Aplicar critérios com dados; não seleccionar pelos rascunhos comerciais" +"MVP","Projectos-piloto, profissões e instituições seleccionados","24/28","Pendente de validação","Levantamento e aprovação formal B01/B06" +"MVP","Mapa, painel, interesse e CRM integrados","15/17/19/23","Parcialmente conforme","Capacidades partilhadas existem; fluxo Education por implementar" +"Expansão","Preparação, priorização, vagas, validação e activação","25","Conforme","Aprovar responsáveis, orçamento e calendário de cada vaga" +"Entrega","Branch, revisão, testes, homologação e Go/No-Go","2/27/28","Pendente de validação","Confirmar checkout Git e executar controlos antes de integração" +"Exclusão","Não acreditar/certificar/emitir diplomas","2/12/21","Conforme","Manter somente referência verificada a decisão externa" +"Exclusão","Sem gestão académica completa nem salários","2/25","Conforme","Interfaces de resultados mínimos, não ERP académico" +"Exclusão","Sem decisão automática de elegibilidade/contratação/financiamento","2/13/21","Conforme","Exigir decisão humana competente" +"Exclusão","Sem classificação pública automática de instituições nem previsão vinculativa","2/20/21","Conforme","Resultados assistivos com limitações" +"Exclusão","Sem integração institucional não formalizada","2/15/25","Conforme","Conectores externos desactivados até acordo" +"Módulo 1","Observatório de competências","4; requirements.csv; entities.csv","Parcialmente conforme","Desenho proposto; implementar após G0 e testar os RF correspondentes" +"Módulo 2","Cadastro de procura de mão-de-obra","4; requirements.csv; entities.csv","Parcialmente conforme","Desenho proposto; implementar após G0 e testar os RF correspondentes" +"Módulo 3","Perfis ocupacionais","4; requirements.csv; entities.csv","Parcialmente conforme","Desenho proposto; implementar após G0 e testar os RF correspondentes" +"Módulo 4","Catálogo de competências","4; requirements.csv; entities.csv","Parcialmente conforme","Desenho proposto; implementar após G0 e testar os RF correspondentes" +"Módulo 5","Cadastro de instituições","4; requirements.csv; entities.csv","Parcialmente conforme","Desenho proposto; implementar após G0 e testar os RF correspondentes" +"Módulo 6","Catálogo de cursos/programas","4; requirements.csv; entities.csv","Parcialmente conforme","Desenho proposto; implementar após G0 e testar os RF correspondentes" +"Módulo 7","Capacidade formativa","4; requirements.csv; entities.csv","Parcialmente conforme","Desenho proposto; implementar após G0 e testar os RF correspondentes" +"Módulo 8","Identificação de lacunas","4; requirements.csv; entities.csv","Parcialmente conforme","Desenho proposto; implementar após G0 e testar os RF correspondentes" +"Módulo 9","Planeamento de respostas","4; requirements.csv; entities.csv","Parcialmente conforme","Desenho proposto; implementar após G0 e testar os RF correspondentes" +"Módulo 10","Investimento em educação","4; requirements.csv; entities.csv","Parcialmente conforme","Desenho proposto; implementar após G0 e testar os RF correspondentes" +"Módulo 11","Financiamento e bolsas","4; requirements.csv; entities.csv","Parcialmente conforme","MVP apenas necessidades/agregados quando aplicável; execução pessoal/financeira F2 depende de B05" +"Módulo 12","Equipamentos/infra-estruturas","4; requirements.csv; entities.csv","Parcialmente conforme","MVP apenas necessidades/agregados quando aplicável; execução pessoal/financeira F2 depende de B05" +"Módulo 13","Parcerias instituição–empregador","4; requirements.csv; entities.csv","Parcialmente conforme","MVP apenas necessidades/agregados quando aplicável; execução pessoal/financeira F2 depende de B05" +"Módulo 14","Aprendizagem e estágios","4; requirements.csv; entities.csv","Parcialmente conforme","MVP apenas necessidades/agregados quando aplicável; execução pessoal/financeira F2 depende de B05" +"Módulo 15","Formação de formadores","4; requirements.csv; entities.csv","Parcialmente conforme","MVP apenas necessidades/agregados quando aplicável; execução pessoal/financeira F2 depende de B05" +"Módulo 16","Beneficiários","4; requirements.csv; entities.csv","Parcialmente conforme","MVP apenas necessidades/agregados quando aplicável; execução pessoal/financeira F2 depende de B05" +"Módulo 17","Inserção profissional","4; requirements.csv; entities.csv","Parcialmente conforme","MVP apenas necessidades/agregados quando aplicável; execução pessoal/financeira F2 depende de B05" +"Módulo 18","Monitoria de resultados","4; requirements.csv; entities.csv","Parcialmente conforme","MVP apenas necessidades/agregados quando aplicável; execução pessoal/financeira F2 depende de B05" +"Módulo 19","IA supervisionada","4; requirements.csv; entities.csv","Parcialmente conforme","Validar B01/B03/B07 e testar segregação e rastreabilidade" +"Módulo 20","Governação/validação/auditoria","4; requirements.csv; entities.csv","Parcialmente conforme","Validar B01/B03/B07 e testar segregação e rastreabilidade" diff --git a/docs/investmoz/education/decisions.md b/docs/investmoz/education/decisions.md new file mode 100644 index 00000000..7ce09ab0 --- /dev/null +++ b/docs/investmoz/education/decisions.md @@ -0,0 +1,48 @@ +# Decisões técnicas e execução — 26/09/2026 + +Estado: **validado tecnicamente para desenvolvimento local**, por revisão do repositório e autorização do utilizador para validar as decisões pendentes e executar. Não representa mandato de uma entidade pública, validação de dados externos ou aprovação de produção. + +## Decisões adoptadas + +| ID | Decisão e fundamento confirmado | Execução e limites | +| --- | --- | --- | +| D-EDU-01 / B02 | O catálogo público usa `opportunities.id` bigint; os domínios novos usam UUID. Preservar ambos, com uma associação explícita, sem converter IDs existentes. | `education_investment_proposals` tem UUID e referências a `labour_demands` e `opportunities`. Uma ficha inicial por procura; restrições únicas e bloqueio transaccional evitam duplicação. `education_opportunities` conserva a semântica de oferta calendarizada. | +| D-EDU-02 / B03 | Reutilizar sessão, email verificado e `manage-portal` para preparação interna. O registo público não concede acesso. | As rotas novas estão no grupo de gestão, atrás de `EducationEnabled`. Não criar perfis institucionais ou OAuth/OIDC sem mandatos/issuer reais. A revisão por outro gestor continua distinta de validação oficial. | +| D-EDU-03 / B06 | Vagas formativas não equivalem a trabalhadores disponíveis. Fontes desconhecidas não equivalem a zero. A versão revista da procura é requisito para preparar uma proposta. | Comparação existente preservada; proposta referencia a versão revista e vigente. Alteração de versão produz conflito na criação e aviso na consulta da ficha anterior. Nenhuma soma de vagas é apresentada como défice laboral. | +| D-EDU-04 / B01 | A preparação de uma resposta pode ocorrer antes da decisão institucional; a publicação exige evidência que ainda não foi fornecida. | Criar apenas rascunhos, com fonte, riscos e intervenção obrigatórios; nenhum montante, instituição, capacidade ou projecto é inventado. A gestão geral também rejeita publicação de fichas educativas. Não chamar este estado de lacuna validada. | +| D-EDU-05 / B04 | Reutilizar a identidade do catálogo e o fluxo de interesse/CRM existente. Não duplicar investidores, contactos ou leads. | Ficha aponta para a oportunidade canónica. O encaminhamento educativo externo ainda não é activado: `suitecrm:probe` devolveu `disabled`; sem CRM homologado não declarar sincronização concluída. | +| D-EDU-06 / B05 | Nesta etapa não recolher beneficiários, dados académicos individuais ou localizações pessoais. | Ficha usa necessidades agregadas; campos de texto devem conter fontes e responsáveis institucionais, não listas de pessoas. Auditoria partilhada não replica o texto das fontes, riscos ou planos. | +| D-EDU-07 / B07 | Correspondência por regras não é IA. Não escolher fornecedor nem enviar dados a um modelo sem configuração e critérios aprovados. | IA permanece por configurar; sem inferência externa, decisão automática ou alegação de precisão. | +| D-EDU-08 / B08 | Indicadores internos devem medir apenas o que os dados sustentam. | Contagens de declarações, procura por rever e fontes expiradas, rotuladas como globais e sem efeitos dos filtros. Sem metas fictícias, taxas de emprego ou agregação indevida de trabalhadores. | +| D-EDU-09 | Alteração aditiva e reversão sem perda. | Nova migration não altera tabelas existentes; rollback recusa apagar fichas existentes. Reversão operacional: desactivar `EDUCATION_ENABLED`, preservar as tabelas e reconciliar antes de qualquer restauro. | + +## Contratos preservados e análise de impacto + +- APIs públicas, IDs, estados e campos do catálogo mantidos. Não há API Education institucional nova. +- Novo POST interno: `/management/education/demands/{id}/investment`. Exige versão, sector existente, título, intervenção, riscos, fonte e moeda de referência. Não define orçamento nem publica automaticamente. +- A resposta de análise ganha `proposal` e `sectors`; a listagem ganha filtros e indicadores. Consumidores React alterados em conjunto. +- A ficha contém necessidades opcionais de equipamentos, formadores e financiamento; ausência significa pendência, nunca custo zero. +- A gestão geral de oportunidades bloqueia publicação apenas das oportunidades ligadas a fichas educativas; oportunidades preexistentes continuam com o contrato anterior. +- A migration deve anteceder a activação do módulo. A verificação de publicação tolera a ausência da nova tabela durante o período de transição. +- O directório não contém `.git`; não foi fabricada uma aprovação por branch ou revisão por pares. Estes controlos continuam obrigatórios antes de integração em repositório partilhado/homologação. + +## O que ainda exige confirmação externa + +1. Pilotos, responsáveis, fontes e mandatos institucionais reais; a autorização técnica não é evidência desses factos. +2. Taxonomias nacionais, equivalências, disponibilidade de trabalhadores e regras de alocação. +3. Processo e documentação de validação institucional para permitir publicação das propostas. +4. SuiteCRM acessível, identidade técnica e testes de sincronização; issuer OAuth/OIDC para API institucional. +5. Políticas de retenção, beneficiários/menores, fornecedor de IA e metas operacionais. + +Estes itens não impedem a execução local das decisões técnicas acima. Continuam a impedir publicação oficial, integração externa e declaração de conclusão integral do MVP. + +## Execução comprovada + +- Ambiente confirmado como `local`, PostgreSQL em loopback. +- Backup custom PostgreSQL, SHA-256 registado e restauro efectuado numa base isolada. Contagens das 66 tabelas reconciliadas antes da migração; o restauro não substituiu a base da aplicação. +- Aplicadas à base local apenas `2026_09_26_100000_education_intake` e `2026_09_26_110000_education_investment_proposals`. +- Após a migração, preservadas as contagens das tabelas existentes; o ledger de migrations ganhou exactamente duas entradas. Nenhum seed de demonstração ou dado institucional foi inserido. +- `EDUCATION_ENABLED=true` no `.env` local. Restantes configurações e lista de gestores preservadas. Seis rotas Education confirmadas. Acesso: `/management/education`, com conta gestora verificada. +- Suite PHP: **109 testes, 961 verificações aprovadas**. TypeScript, build e PHPStan dos controllers/modelo afectados aprovados. Não houve validação visual em navegador ou homologação institucional. +- Backup e manifesto mantidos em `storage/app/private/education-backup-20260926-105520/`; inclui cópia protegida do ambiente anterior. Não publicar nem versionar esse directório. +- Para reversão funcional, definir `EDUCATION_ENABLED=false` e limpar a cache de configuração. Conservar as fichas e tabelas; não executar rollback destrutivo de dados. diff --git a/docs/investmoz/education/entities.csv b/docs/investmoz/education/entities.csv new file mode 100644 index 00000000..65e92a4d --- /dev/null +++ b/docs/investmoz/education/entities.csv @@ -0,0 +1,45 @@ +"entidade","dominio_reutilizacao","campos_tipo_obrigatoriedade","regra_duplicacao","estados","fonte_responsavel","fase","classificacao","validade_historico","permissoes_retencao","estado_artefacto" +"LabourDemand","Education: proposta; complementar training_needs","occupation_id:uuid!;employer_id:uuid!;territory_id:uuid!;project_id:uuid?;cluster_id:uuid?;corridor_id:uuid?;value_chain_id:uuid?;sector_id:uuid?;activity:text!;phase:text!;quantity:int!;qualification_level_id:uuid!;hiring_date:date!;end_date:date?;nature:enum!;workplace:text!;experience:text?;safety:text?;languages:json?;technology:json?;local_hiring:bool?;internship:bool?","Empregador declara postos distintos por ocupação; quantidade >0; contexto produtivo obrigatório; temporário exige fim","submetida/em_analise/validada/rejeitada/expirada/suspensa","empregador","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"SkillGap","Education: nova proposta","demand_version_ids:uuid[]!;supply_version_ids:uuid[]!;territory_id:uuid!;period_start:date!;period_end:date!;occupation_id:uuid!;qualification_id:uuid!;demand_people:int?;available_people:int?;gap_people:int?;gap_kind:enum!;qualitative_gaps:json!;method_version:text!;confidence:enum!;limitations:text!","Só calcular valores comparáveis; desconhecido não é zero; cálculo versionado; encerramento exige resultado comprovado","declarada/observada/estimada/projectada/validada/encerrada/reaberta","analista e validador","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Occupation","Education: reutilizar occupations","id:uuid!;code:text!;name:text!;taxonomy_version:text?;description:text?;skill_links:uuid[]?","Código único por taxonomia/versão proposta; sinónimos não criam novos postos","rascunho/validada/retirada (propostos)","secretariado de dados","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Skill","Education: reutilizar skills","id:uuid!;code:text!;name:text!;synonyms:json?;proficiency_scale:text?;taxonomy_version:text?","Versões e sinónimos sujeitos a aprovação; não inferir competência pessoal","rascunho/validada/retirada (propostos)","secretariado de dados","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"QualificationLevel","Education: nova proposta","code:text!;name:text!;framework:text!;framework_version:text!;level_order:int?;authority_id:uuid!","Não equiparar níveis entre quadros sem correspondência oficial","proposto/validado/retirado","entidade competente","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Employer","Shared: papel sobre organizations","organization_id:uuid!;mandate_document_id:uuid!;sector_ids:uuid[]?;territory_ids:uuid[]?","Não duplicar pessoa colectiva; representação válida antes de submeter","declarado/verificado/suspenso","organização e gestor de mandatos","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"TrainingInstitution","Education: papel institucional de training_providers","provider_id:uuid!;institution_type:enum!;ownership:enum!;coverage_ids:uuid[]!;accessibility:text?;languages:json?","Mesma instituição não gera um segundo cadastro; estatuto separado de acreditação","declarada/verificada/suspensa","instituição","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"TrainingProvider","Education: reutilizar training_providers","id:uuid!;organization_id:uuid?;name:text!;status:enum!;territory_id:uuid?","candidate/approved/inactive existentes não significam acreditação; organização obrigatória antes de publicação proposta","candidate/approved/inactive (existentes)","instituição","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"TrainingProgramme","Education: nova proposta","provider_id:uuid!;title:text!;gap_ids:uuid[]!;course_ids:uuid[]?;modality:enum!;duration_hours:int!;entry_profile:text!;calendar:json!","Percurso pode conter vários cursos; duração e calendário compatíveis com contratação","proposto/em_revisao/validado/em_execucao/encerrado","instituição e empregador","F1 resposta proposta; execução F2","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Course","Education: reutilizar courses","id:uuid!;provider_id:uuid!;code:text!;title:text!;status:enum!;skill_ids:uuid[]?;qualification_id:uuid?;curriculum_version_id:uuid?","Publicação editorial não equivale a certificação; código único dentro da instituição","draft/published/retired (existentes)","instituição","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"TrainingCapacity","Education: nova proposta","provider_id:uuid!;course_id:uuid!;territory_id:uuid!;period_start:date!;period_end:date!;seats_total:int!;seats_used:int!;completion_date:date!;resource_constraints:json!;allocation_pool_id:uuid!","0<=utilizadas<=total; vagas são lugares de formação, não trabalhadores disponíveis; pool impede dupla alocação","declarada/em_analise/validada/expirada","instituição","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Curriculum","Education: nova proposta","course_id:uuid!;version:text!;document_id:uuid!;learning_outcomes:json!;review_authority_id:uuid?","Parecer portal distinto de decisão curricular externa; nunca aprovar oficialmente pelo gestor","submetido/em_revisao/decisao_externa_recebida/expirado","entidade competente","F1 referência; tramitação F2/F3","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"AccreditationRecord","Education: referência a decisão externa","provider_id:uuid!;course_id:uuid?;authority_id:uuid!;decision_reference:text!;document_id:uuid!;valid_from:date!;valid_until:date?;scope:text!;verification_id:uuid!","Evidência, mandato e verificação independente; prazo desconhecido não é vitalício; gestor não declara acreditação","declarada/em_verificacao/confirmada/expirada/suspensa/rejeitada","entidade acreditadora","F1 declaração; recepção automatizada F3","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"CertificationRecord","Education: referência restrita a decisão externa","issuer_id:uuid!;learner_id:uuid?;course_id:uuid!;reference:text!;document_id:uuid!;issued_at:date!;valid_until:date?","Não emitir certificado; verificar emissor, escopo e autorização do titular; não expor a investidores","reportada/em_verificacao/confirmada/revogada/expirada","entidade certificadora","F2/F3","restricted","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Trainer","Education: extensão de pessoa/contacto autorizado","contact_id:uuid!;provider_id:uuid!;skill_ids:uuid[]!;availability_hours:int?;evidence_document_ids:uuid[]?","Sem classificação oficial automática; disponibilidade depende de finalidade e autorização","declarado/verificado/inactivo","instituição e titular","F2; contagens agregadas F1","restricted","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Facility","Education: nova proposta","provider_id:uuid!;territory_id:uuid!;facility_type:enum!;capacity:int?;accessibility:text?;document_id:uuid?","Reutiliza localização Shared; capacidade precisa de unidade e fonte","declarada/validada/indisponivel","instituição","F1 inventário; execução F2","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Laboratory","Education: especialização Facility","facility_id:uuid!;specialism:text!;workstations:int?;safety_requirements:text!","Não duplicar instalação; laboratório vinculado ao mesmo UUID da instalação","herda Facility","instituição","F1 inventário; execução F2","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"EquipmentNeed","Education: nova proposta","gap_id:uuid!;facility_id:uuid?;specification:text!;quantity:int!;unit:text!;estimated_amount:decimal(20,2)?;currency:char(3)?","Estimativa com fonte/data; equipamento não equivale a capacidade operacional instalada","identificada/validada/financiada/instalada/verificada","instituição","F1 especificação; aquisição F2","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"EquipmentSupplier","Shared: papel sobre organizations","organization_id:uuid!;equipment_categories:json!;coverage_ids:uuid[]?","Sem duplicar fornecedor; registo não endossa habilitação para concursos","declarado/verificado/suspenso","fornecedor","F1 interesse; execução F2","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"EducationInvestmentOpportunity","Education: proposta distinta de oferta de curso","skill_gap_id:uuid!;promoter_id:uuid!;title:text!;problem:text!;justification:text!;territory_id:uuid!;beneficiaries:int!;intervention:enum!;capex:decimal(20,2)?;opex:decimal(20,2)?;currency:char(3)?;funding_model:text!;sustainability:text!;calendar:json!;risks:text!;cta:text!;document_ids:uuid[]?","Sem curso obrigatório; requer lacuna validada e autorização editorial; budgets não inventados; ligação ao catálogo Opportunity a decidir B02","rascunho/em_revisao/publicada/suspensa/encerrada","promotor e revisor independente","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"FundingNeed","Education: nova proposta","opportunity_id:uuid!;amount:decimal(20,2)!;currency:char(3)!;instrument:text!;source_date:date!;funded_amount:decimal(20,2)?","Valores em moedas distintas não somáveis sem câmbio datado e fonte","estimada/validada/parcial/coberta","promotor","F1 resumo; gestão F2","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Funder","Shared: papel organization/investor","organization_id:uuid!;investor_id:uuid?;instruments:json!;eligibility_reference:text?","Interesse não constitui compromisso nem aprovação financeira","prospect/activo/inactivo","financiador","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Investor","Shared: reutilizar investors e investor_accounts","id:uuid!;organization_id:uuid?;legal_name:text!;investor_type:enum!;status:enum!","Mandato distinto da mera conta; não criar segunda identidade por interesse educativo","prospect/active/inactive existentes","titular e CRM","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Scholarship","Education: nova proposta","funder_id:uuid!;programme_id:uuid!;budget:decimal(20,2)!;currency:char(3)!;criteria_document_id:uuid!;places:int!;deadline:date!","Elegibilidade decidida por pessoa autorizada; reserva não ultrapassa orçamento","proposta/aprovada/aberta/fechada","financiador","F2","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Internship","Education: nova proposta","learner_id:uuid!;employer_id:uuid!;programme_id:uuid!;start_date:date!;end_date:date!;supervisor_id:uuid!;agreement_document_id:uuid!","Consentimento e acordo; estágio não equivale a emprego","proposto/acordado/activo/concluido/interrompido","empregador e instituição","F2","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Apprenticeship","Education: nova proposta","learner_id:uuid!;employer_id:uuid!;course_id:uuid!;work_hours:int!;training_hours:int!;agreement_document_id:uuid!","Horas coerentes; validação curricular externa quando aplicável","proposta/acordada/activa/concluida","instituição e empregador","F2","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"TrainingCohort","Education: nova proposta","programme_id:uuid!;capacity_id:uuid!;start_date:date!;end_date:date!;planned_places:int!;allocation_pool_id:uuid!","Vagas reservadas atomicamente; unidade pessoa distinta; nenhum aluno contado duas vezes","planeada/aberta/em_execucao/concluida/cancelada","instituição","F2","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Learner","Education: referência pessoal restrita Shared","person_or_contact_id:uuid!;consent_id:uuid!;programme_ids:uuid[]?;eligibility_evidence:uuid[]?","Não duplicar Contact; menores e dados sensíveis exigem decisão específica antes de recolha","inscrito/activo/retirado (propostos)","titular e instituição","F2","restricted","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"CompletionRecord","Education: nova proposta","learner_id:uuid!;cohort_id:uuid!;completed_at:date!;result_source_id:uuid!;evidence_document_id:uuid!","Conclusão não é certificação; unicidade pessoa/turma/versão","reportado/verificado/corrigido","instituição","F2","restricted","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"EmploymentOutcome","Education: nova proposta","learner_id:uuid!;employer_id:uuid!;project_id:uuid?;occupation_id:uuid!;employment_start:date!;observation_date:date!;retention_days:int?;consent_id:uuid!;evidence_id:uuid!","Deduplicar pessoa e vínculo; observação sem resposta não é desemprego; atribuição ao programa não é causalidade","reportado/confirmado/contestado/corrigido","empregador e titular","F2","restricted","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Partnership","Education: acordo referenciado; contraparte Shared","provider_id:uuid!;employer_id:uuid!;programme_id:uuid?;agreement_document_id:uuid!;valid_from:date!;valid_until:date!;commitments:json!","Interesse CRM não é acordo formal; representantes válidos","proposta/assinada/activa/expirada/suspensa","instituição e empregador","F2","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Territory","Shared: reutilizar geo_locations/geo_geometries","id:uuid!;parent_id:uuid?;code:text!;geometry_ref:uuid?","SRID 4326 e geometria válida; nível territorial autorizado; sem coordenadas de formandos","estado canónico; publicação autorizada","responsável GIS","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Cluster","Shared: economic_clusters","id:uuid!;code:text!;name:text!;status:enum!","Referência canónica; até três activados no piloto após aprovação","planned/active/inactive existentes","coordenador","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Corridor","Shared: development_corridors","id:uuid!;code:text!;name:text!;status:enum!","Referência canónica; até três activados no piloto após aprovação","proposed/active/closed existentes","coordenador","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Project","Shared: projects","id:uuid!;code:text!;name:text!;status:enum!","Não transformar os 15 rascunhos em pilotos validados; complementar usa relações existentes","estados existentes","promotor","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"ValueChain","Shared: value_chains/value_chain_links","id:uuid!;cluster_id:uuid!;code:text!;stage:enum!","Elo pertence ao cluster referenciado; sem relacionamento cruzado inválido","estados existentes","coordenador de cluster","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"DataSource","Shared: reutilizar registo se confirmado; extensão proposta","owner_organization_id:uuid!;name:text!;purpose:text!;licence_document_id:uuid?;reference_date:date!;valid_until:date!;methodology:text!;access_scope:text!","Nenhuma fonte real autorizada presumida; versão imutável para cálculo","proposta/autorizada/suspensa/expirada","secretariado de dados","F0/F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"ValidationDecision","Education: proposta ligada a audit_events","entity_type:text!;entity_id:uuid!;entity_version:int!;decision:enum!;reviewer_user_id:bigint!;authority_id:uuid?;evidence_document_id:uuid!;reason:text!;decided_at:timestamptz!","Submissor não valida; autor da recomendação não aprova; nova versão exige nova decisão","aprovada/rejeitada/devolvida/suspensa","validador mandatado","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Consent","Shared: consents; extensão granular a validar","id:uuid!;subject_ref:uuid!;purpose:text!;recipient_ref:uuid?;version:int!;granted_at:timestamptz!;revoked_at:timestamptz?;valid_until:timestamptz?","Revogação impede novas utilizações; base legítima não se reduz a checkbox; campos exactos propostos devem ser reconciliados","concedido/revogado/expirado","titular e responsável de dados","F1","restricted","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"AuditEvent","Shared: audit_events e PortalAudit","id:uuid!;actor_user_id:bigint?;entity_id:uuid?;action:text!;before_data:jsonb?;after_data:jsonb?;correlation_id:uuid?","Imutável; não copiar documentos, dados pessoais ou tokens; UUID não concede acesso","append-only","auditor","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Organisation","Shared: organizations","id:uuid!;legal_name:text!;external_id:text?","Chaves oficiais e deduplicação institucional; não criar tabela paralela Education","estados canónicos","gestor de dados mestres","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Contact","Shared: contacts","id:uuid!;full_name:text!;email:text?;phone:text?","Dados mínimos de relacionamento; mandato separado; sem atribuir identidade por email coincidente","estado canónico","titular e CRM","F1","restricted","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Document","Shared: documents e education_documents","id:uuid!;classification:enum!;version:int!;storage_reference:text!;checksum:text!;valid_until:date?","Não duplicar cofre; schema exacto e antivírus ainda por confirmar; descarga autorizada a cada acesso","recebido/em_verificacao/disponivel/quarentena/expirado (propostos)","proprietário e responsável documental","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" +"Opportunity","Shared: opportunities e crm_opportunities","portal_id:bigint?;crm_id:uuid?;integration_id:uuid?","São identidades distintas existentes; não converter bigint em UUID nem duplicar pipeline","estados dos respectivos domínios","dono do produto e CRM","F1","internal; public apenas projecção aprovada","Envelope E0; versões e decisões; validade por fonte","Matriz §13; retenção R0 pendente; sem eliminação automática","Proposta" diff --git a/docs/investmoz/education/examples/gap-calculator.mjs b/docs/investmoz/education/examples/gap-calculator.mjs new file mode 100644 index 00000000..46e2a85e --- /dev/null +++ b/docs/investmoz/education/examples/gap-calculator.mjs @@ -0,0 +1,47 @@ +// PROPOSTA DIDACTICA. Dados sinteticos; nao integrado no Laravel ou no portal. +// Nao e IA nem apuramento oficial de lacunas. Nao modela alocacao entre projectos. +export function observedGap({ + demand, + available, + context, + supplyContext, + sourceValid, +}) { + if (typeof sourceValid !== 'boolean') + throw new TypeError('Validade explicita obrigatoria'); + const dimensions = [ + 'occupation', + 'qualification', + 'territory', + 'period', + 'unit', + ]; + for (const key of dimensions) { + if ( + typeof context?.[key] !== 'string' || + !context[key].trim() || + typeof supplyContext?.[key] !== 'string' || + !supplyContext[key].trim() + ) { + throw new TypeError('Contexto completo obrigatorio'); + } + } + for (const value of [demand, available]) { + if (value !== null && (!Number.isSafeInteger(value) || value < 0)) { + throw new TypeError('Quantidade inteira nao negativa ou null'); + } + } + if (!sourceValid) return { state: 'expired', people: null }; + if ( + context.unit !== 'people' || + dimensions.some((key) => context[key] !== supplyContext[key]) + ) { + return { state: 'incomparable', people: null }; + } + if (demand === null || available === null) + return { state: 'insufficient_data', people: null }; + return { + state: 'observed_unvalidated', + people: Math.max(0, demand - available), + }; +} diff --git a/docs/investmoz/education/examples/gap-calculator.test.mjs b/docs/investmoz/education/examples/gap-calculator.test.mjs new file mode 100644 index 00000000..67506e84 --- /dev/null +++ b/docs/investmoz/education/examples/gap-calculator.test.mjs @@ -0,0 +1,77 @@ +// Casos sinteticos de especificacao; nao demonstram RF-EDU implementados. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { observedGap } from './gap-calculator.mjs'; +const context = { + occupation: 'SYNTHETIC-OCC', + qualification: 'SYNTHETIC-Q', + territory: 'SYNTHETIC-T', + period: 'SYNTHETIC-P', + unit: 'people', +}; +const input = { + demand: 10, + available: 4, + context, + supplyContext: { ...context }, + sourceValid: true, +}; +await test('comparable synthetic people produce an unvalidated deficit', () => { + assert.deepEqual(observedGap(input), { + state: 'observed_unvalidated', + people: 6, + }); +}); +await test('unknown supply is not zero; zero supply is explicitly distinguishable', () => { + assert.deepEqual(observedGap({ ...input, available: null }), { + state: 'insufficient_data', + people: null, + }); + assert.equal(observedGap({ ...input, available: 0 }).people, 10); +}); +await test('training seats cannot count as available workers', () => { + assert.equal( + observedGap({ ...input, supplyContext: { ...context, unit: 'seats' } }) + .state, + 'incomparable', + ); +}); +await test('incompatible occupation, qualification, territory or period is excluded', () => { + for (const key of ['occupation', 'qualification', 'territory', 'period']) { + assert.equal( + observedGap({ + ...input, + supplyContext: { ...context, [key]: 'different' }, + }).state, + 'incomparable', + ); + } +}); +await test('expired sources never produce a current deficit', () => { + assert.deepEqual(observedGap({ ...input, sourceValid: false }), { + state: 'expired', + people: null, + }); +}); +await test('surplus never produces a negative deficit or an institutional decision', () => { + assert.deepEqual(observedGap({ ...input, available: 20 }), { + state: 'observed_unvalidated', + people: 0, + }); +}); +await test('invalid counts, missing context and implicit validity are rejected', () => { + for (const demand of [ + -1, + 1.5, + undefined, + '10', + Number.MAX_SAFE_INTEGER + 1, + ]) { + assert.throws(() => observedGap({ ...input, demand }), TypeError); + } + assert.throws(() => observedGap({ ...input, context: {} }), TypeError); + assert.throws( + () => observedGap({ ...input, sourceValid: undefined }), + TypeError, + ); +}); diff --git a/docs/investmoz/education/examples/package.test.mjs b/docs/investmoz/education/examples/package.test.mjs new file mode 100644 index 00000000..47487441 --- /dev/null +++ b/docs/investmoz/education/examples/package.test.mjs @@ -0,0 +1,112 @@ +// Verificacao de consistencia documental, nao validacao funcional do portal. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +const root = new URL('../', import.meta.url); +async function csv(name) { + const text = await readFile(new URL(name, root), 'utf8'); + const lines = text.trim().split(/\r?\n/); + const parse = (line) => + [...line.matchAll(/"((?:[^"]|"")*)"(?:,|$)/g)].map((match) => + match[1].replaceAll('""', '"'), + ); + const header = parse(lines.shift()); + return lines.map((line) => { + const cells = parse(line); + assert.equal(cells.length, header.length, name + ': numero de colunas'); + return Object.fromEntries(header.map((key, i) => [key, cells[i]])); + }); +} +await test('33 requirements retain IDs and resolve entities, rules, risks and acceptance tests', async () => { + const rows = await csv('requirements.csv'); + const entities = new Set( + (await csv('entities.csv')).map((e) => e.entidade), + ); + const spec = await readFile(new URL('specification.md', root), 'utf8'); + assert.equal(rows.length, 33); + assert.equal(new Set(rows.map((r) => r.id)).size, rows.length); + for (const [index, row] of rows.entries()) { + assert.equal(row.id, 'RF-EDU-' + String(index + 1).padStart(3, '0')); + for (const entity of row.dados_entidades.split(';')) + assert.ok(entities.has(entity), entity); + for (const rule of row.regra_negocio.split(';')) + assert.ok(spec.includes(rule), rule); + assert.ok(spec.includes(row.risco), row.risco); + assert.ok(row.criterio_aceitacao.length > 20); + assert.ok(row.endpoint_proposto.startsWith('/api/v1/education/')); + assert.equal(row.estado, 'Proposta'); + } +}); +await test('entities, RNF, indicators and conformity matrices are complete and explicitly proposed', async () => { + assert.equal((await csv('entities.csv')).length, 44); + assert.equal((await csv('nonfunctional.csv')).length, 18); + const indicators = await csv('indicators.csv'); + assert.equal(indicators.length, 25); + for (const indicator of indicators) + assert.match(indicator.meta, /Pendente/); + const conformity = await csv('conformity.csv'); + assert.equal( + conformity.filter((r) => r.categoria.startsWith('Módulo ')).length, + 20, + ); + for (const item of conformity) + assert.ok( + [ + 'Conforme', + 'Parcialmente conforme', + 'Não conforme', + 'Pendente de validação', + ].includes(item.classificacao), + ); +}); +await test('OpenAPI proposal resolves local refs and guards all modifying operations', async () => { + const api = JSON.parse( + await readFile(new URL('openapi.proposed.json', root), 'utf8'), + ); + assert.equal(api.openapi, '3.1.0'); + assert.equal(api['x-artifact-status'], 'Proposta'); + const operations = new Set(); + function walk(value) { + if (!value || typeof value !== 'object') return; + if (value.$ref) { + assert.ok(value.$ref.startsWith('#/')); + let target = api; + for (const part of value.$ref.slice(2).split('/')) + target = + target?.[part.replaceAll('~1', '/').replaceAll('~0', '~')]; + assert.ok(target, value.$ref); + } + for (const nested of Object.values(value)) walk(nested); + } + walk(api); + for (const [path, methods] of Object.entries(api.paths)) { + assert.ok(path.startsWith('/api/v1/education/')); + for (const method of ['get', 'post', 'patch']) { + const operation = methods[method]; + if (!operation) continue; + assert.ok(!operations.has(operation.operationId)); + operations.add(operation.operationId); + assert.ok(operation.responses['401']); + if (method !== 'get') + assert.ok( + operation.parameters.some((p) => + p.$ref.endsWith('/Idempotency'), + ), + ); + if (method === 'patch' || path.endsWith('/decisions')) + assert.ok( + operation.parameters.some((p) => p.$ref.endsWith('/Match')), + ); + } + } +}); +await test('all thirty requested numbered sections exist in order', async () => { + const text = await readFile(new URL('specification.md', root), 'utf8'); + const numbers = [...text.matchAll(/^## (\d+)\. /gm)].map((m) => + Number(m[1]), + ); + assert.deepEqual( + numbers, + Array.from({ length: 30 }, (_, i) => i + 1), + ); +}); diff --git a/docs/investmoz/education/implementation-status.md b/docs/investmoz/education/implementation-status.md new file mode 100644 index 00000000..130852ad --- /dev/null +++ b/docs/investmoz/education/implementation-status.md @@ -0,0 +1,92 @@ +# Execução e lacunas do submódulo Education + +> **Revisão posterior, 26/09/2026:** as escolhas técnicas B02 e de preparação interna B03/B06 foram decididas e implementadas conforme [decisions.md](decisions.md). RF-EDU-005/010/022/023/024 passam de bloqueados a parciais para preparação de fichas; RF-EDU-015 inclui pesquisa e indicadores de registos. As pendências institucionais e a publicação permanecem bloqueadas. O diagnóstico original abaixo é mantido como histórico da revisão anterior. + +Actualizado em 26/09/2026. Estado: **implementação interna parcial, em revisão**. Não equivale a aprovação institucional, homologação ou produção. Este relatório actualiza o diagnóstico inicial da especificação, sem promover as propostas a requisitos cumpridos. + +## 1. Resultado verificado + +O repositório já continha `EducationController`, `LabourDemand`, `TrainingCapacity`, duas páginas React, configuração de activação e a migration `2026_09_26_100000_education_intake.php`. A documentação inicial descrevia uma entrega anterior, exclusivamente de concepção. Não se deve interpretar a presença deste código como conclusão do MVP. + +Nesta revisão: + +- Corrigido o registo do middleware: a função anónima impedia o carregamento das rotas do portal. Reutilizado `EducationEnabled`. +- Navegação condicionada à configuração `education.enabled`; incluída `EDUCATION_ENABLED=false` no exemplo de ambiente. O `.env` real não foi alterado. +- Preservados os parâmetros de paginação das duas listagens. +- Excluídos cursos retirados/eliminados e instituições inactivas/eliminadas da oferta candidata; impedida a submissão de capacidade para esses cursos. +- Corrigida a regra de existência de instituições na criação de cursos, usando a condição de exclusão suportada pelo validador. +- Testados acesso, estados não oficiais, segregação de funções, conflito de versões, expiração, auditoria e distinção entre vagas e trabalhadores. + +As rotas continuam internas, sob `/management/education`, com sessão, verificação de email e `manage-portal`. Não implementam os contratos REST/OAuth/OIDC propostos em `/api/v1/education` nem mandatos institucionais. O cálculo apresentado permanece indeterminado quando falta oferta de trabalhadores comparável; não subtrai vagas de formação da procura laboral. + +## 2. Rastreabilidade da execução + +“Parcial” significa código existente com limitações concretas; nenhum RF abaixo foi declarado integralmente cumprido. Os critérios completos mantêm-se em `requirements.csv`. + +| RF-EDU | Estado funcional | Evidência e trabalho por concluir | +| --- | --- | --- | +| 001 | Parcial | Cadastro de código/nome; faltam taxonomia, sinónimos, níveis, fontes e revisão de versões. B01/B06. | +| 002 | Parcial | Procura por projecto, ocupação, competência principal, território e período; faltam múltiplas competências, contexto produtivo completo e mandatos. B01/B03. | +| 003 | Parcial | Instituições, cursos e capacidade declarada; faltam cobertura, documentação e vínculo institucional verificado. B01/B03. | +| 004 | Parcial | Calculador e correspondência determinística; faltam disponibilidade comprovada de trabalhadores e alocação sem dupla contagem. B06. | +| 005 | Bloqueado | Confirmar extensão do catálogo canónico; `education_opportunities` representa oferta calendarizada, não investimento educativo. B02. | +| 006 | Parcial | Navegação interna e análise por procura; falta navegação projecto/cluster/corredor com relações confirmadas. B01/B02. | +| 007 | Bloqueado | Não atribuir acreditação por estado de cadastro; faltam autoridade, evidência e mandato. B01/B03. | +| 008 | Bloqueado | Fluxo geral de interesse existe no portal; associação educativa depende de oportunidade canónica aprovada. B02/B05. | +| 009 | Parcial | Empregador declarado como texto; falta identidade institucional e distinção entre estimativa e compromisso. B01/B03. | +| 010 | Bloqueado | Plano formativo permanece proposta; exige lacuna validada e responsáveis. B01/B02/B06. | +| 011 | Parcial | Critérios apresentados: ocupação, competência, qualificação textual, território, validade e conclusão; faltam equivalências e revisão da correspondência. B06. | +| 012 | Fase 2 | Resultados educativos fora da execução actual; requerem tratamento autorizado. B05. | +| 013 | Fase 2 | Bolsas e campanhas permanecem no roteiro. B05. | +| 014 | Bloqueado | Sem serviço de IA Education activado; fornecedor/modelo, fontes e métricas pendentes. B07. | +| 015 | Parcial | Listagens paginadas; faltam pesquisa, filtros e painel reconciliado. B01/B06/B08. | +| 016 | Bloqueado | Rotas web não equivalem à API proposta; faltam identidade, contratos e eventos aprovados. B03. | +| 017 | Parcial | Cadastro mínimo de ocupações; faltam perfis, relações com competências e níveis validados. B06. | +| 018 | Parcial | Datas e território declarados; sem previsão, mobilidade ou ligação contextual completa. B01/B06. | +| 019 | Parcial | Vagas por curso/período; faltam recursos partilhados, capacidade instalada e utilização. B06. | +| 020 | Bloqueado | Revisão de declarações é interna; não existe ciclo institucional de validação, encerramento e reabertura da lacuna. B01/B03/B06. | +| 021 | Bloqueado | CRM geral reutilizável; vínculo educativo e sincronização dependem de B02/B04/B05. | +| 022 | Bloqueado | Necessidades de equipamento/instalações dependem da resposta formativa aprovada. B02/B06. | +| 023 | Bloqueado | Necessidades de formadores dependem da metodologia; dados individuais apenas na fase autorizada. B05/B06. | +| 024 | Bloqueado | Estimativas e sustentabilidade dependem de fontes e modelo de oportunidade aprovado. B02/B06. | +| 025 | Fase 2 | Parcerias no roteiro; responsabilidades e contratos por validar. | +| 026 | Fase 2 | Estágios e aprendizagem no roteiro; sem contratação automática. | +| 027 | Fase 2 | Turmas e beneficiários não recolhidos. B05. | +| 028 | Fases 2/3 | Conclusão e certificação externa dependem de fontes competentes; portal não certifica. | +| 029 | Fase 2 | Inserção e absorção exigem evidência autorizada e confirmação do empregador. B05. | +| 030 | Fase 2 | Retenção exige períodos, seguimento e base de tratamento definidos. B05. | +| 031 | Bloqueado | Referência territorial existente; mapa/relatórios Education requerem fontes geográficas e divulgação autorizadas. B01/B05/B06. | +| 032 | Parcial | Gate de gestão, revisão independente e auditoria partilhada; faltam mandatos e matriz de permissões institucionais. B03. | +| 033 | Parcial | Validação de datas, fontes, referências e concorrência; migração de fontes reais, reconciliação e retenção pendentes. B05/B06/B08. | + +## 3. Validação local + +Ambiente de teste novo e isolado: PostgreSQL/PostGIS local, porta 55440, base `investmoz_education_test`; PHP 8.5.11 com driver PostgreSQL. Não foi usada a base da aplicação para migrations ou testes. Não foram inseridos dados institucionais reais. + +| Verificação | Evidência | +| --- | --- | +| Migrations | Aplicadas com sucesso na base temporária inicialmente vazia. | +| PHPUnit | 107 testes, 884 verificações, todos aprovados. Inclui 9 testes Education. | +| Exemplos e consistência documental | 11 testes Node aprovados; não equivalem a conformidade funcional integral. | +| TypeScript | `tsc --noEmit` aprovado. | +| Build | `npm run build` aprovado. | +| Análise estática | PHPStan nível 7, zero erros nos seis ficheiros Education/middleware analisados. | +| Formatação PHP | Pint aprovado nos cinco ficheiros PHP alterados. | +| Confirmação final Education | 9 testes, 136 verificações aprovadas após a última correcção do controller. | + +Os testes Education usam transacções revertidas e verificam que o destino termina em `_test`. A suite preexistente usa também recriação do esquema, exclusivamente na base descartável. Não foram efectuados ensaios de carga, auditoria independente de segurança, validação visual em navegador, testes end-to-end ou homologação institucional. Não transferir a aprovação destes testes para esses controlos. + +## 4. Decisões necessárias para concluir o MVP + +| Decisão concreta | Proposta para revisão | Responsável | Condição de avanço | +| --- | --- | --- | --- | +| Identidade da oportunidade educativa | Estender o domínio canónico de oportunidades, mantendo a oferta calendarizada separada; preservar IDs e contratos existentes. | Produto e Arquitectura | Aprovar mapeamento de identidade, campos, vínculo CRM e compatibilidade. B02. | +| Autoridade e permissões | Gestão interna não concede mandato institucional. Vincular validação a organização, âmbito e validade do mandato; manter autor diferente do revisor. | Segurança e entidades competentes | Aprovar matriz e comprovar OAuth/OIDC antes de API institucional. B03. | +| Dados e metodologia | Comparar pessoas disponíveis com procura; capacidade formativa é uma resposta potencial, sujeita a calendário, conclusão e recursos partilhados. | Secretariado e entidades competentes | Validar fontes, taxonomia, unidade, cobertura, actualidade e regra de alocação. B01/B06. | +| Piloto | Até três clusters e até três corredores, seleccionados pela matriz da especificação. Nenhum seleccionado artificialmente. | Patrocinador e Produto | Identificar projectos, instituições, responsáveis e fontes autorizadas. B01. | +| CRM e IA | Reutilizar integração existente após contratos; IA apenas recomendação revista, sem estados oficiais. | Responsável CRM e Comité de IA e Dados | Homologar sincronização, fornecedor/modelo e métricas. B04/B07. | +| Operação e dados pessoais | Manter beneficiários fora desta implementação; definir retenção, divulgação, suporte, backup e restauro. | Dados, Segurança e Operações | Resolver B05/B08 e executar gates da especificação. | + +O texto de origem exige: «Quando faltar informação do repositório ou da arquitectura existente, produz uma especificação, interface ou pseudocódigo marcado como Pendente de validação, e não código executável apresentado como definitivo». Exige ainda que conflitos sejam assinalados como Bloqueio. Por isso, este diagnóstico não cria tabelas, estados oficiais, integrações ou identidades para contornar as decisões acima. + +**Go/No-Go: No-Go para produção e para declarar o MVP concluído.** Próxima entrega de implementação depende dessas decisões documentadas; as fases 2–4 permanecem no roteiro da especificação. diff --git a/docs/investmoz/education/indicators.csv b/docs/investmoz/education/indicators.csv new file mode 100644 index 00000000..149f8b2d --- /dev/null +++ b/docs/investmoz/education/indicators.csv @@ -0,0 +1,26 @@ +"id","indicador","definicao_formula","unidade","fonte_proposta","frequencia","responsavel","linha_base","meta","validacao","divulgacao","segmentacao","estado" +"IND-EDU-001","Procura de mão-de-obra","Soma de postos distintos válidos na janela; deduplicar por lote produtivo","postos","LabourDemand validada","mensal","Empregadores/Secretariado","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-002","Lacuna quantitativa","max(0,D-S) com população/unidade/janela comparáveis; null se D ou S desconhecidos","pessoas","Snapshots SkillGap","mensal","Analista","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-003","Lacuna qualitativa","Requisitos críticos sem oferta compatível / requisitos críticos avaliados; também lista nominal de requisitos","percentagem e lista","Competências e correspondências verificadas","mensal","Analista/Instituição","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-004","Capacidade formativa","Min das capacidades limitantes compatíveis; soma entre pools independentes, não cursos que partilham recursos","lugares por período","TrainingCapacity validada","trimestral","Instituição","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-005","Vagas","Lugares disponíveis = total validado - ocupados - reservados, sem dupla reserva","lugares","Ledger de alocação","semanal","Instituição","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-006","Matrículas","Pessoas distintas com inscrição confirmada por turma; apresentar episódios separadamente","pessoas","TrainingCohort/Learner","mensal","Instituição","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-007","Conclusão","Concluintes verificados / inscritos elegíveis na coorte encerrada","percentagem","CompletionRecord","por coorte","Instituição","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-008","Certificação confirmada","Pessoas com decisão externa confirmada / concluintes elegíveis à certificação; pendentes separados","percentagem","CertificationRecord verificado","por coorte","Emissor competente","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-009","Estágio","Pessoas com estágio iniciado comprovado / elegíveis ao estágio","percentagem","Internship e acordos","mensal","Instituição/Empregador","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-010","Colocação profissional","Pessoas com emprego confirmado / concluintes elegíveis acompanhados; publicar também cobertura de seguimento","percentagem","EmploymentOutcome","trimestral","Empregador","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-011","Retenção","Pessoas ainda empregadas no intervalo aprovado / colocados elegíveis a esse seguimento; não respondentes separados","percentagem","Observações EmploymentOutcome","por janela","Empregador","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-012","Tempo para preencher vagas","Mediana da data de contratação confirmada menos data de abertura; vagas não preenchidas como censuradas","dias","LabourDemand e vínculos confirmados","mensal","Empregador","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-013","Participação local","Participantes da área definida e autorizada / participantes com informação válida; não inferir por IP","percentagem","Agregados territoriais autorizados","trimestral","Responsável de dados","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-014","Grupos prioritários","Participantes voluntariamente identificados em grupo aprovado / participantes com dados autorizados; omitir grupos pequenos","percentagem","Agregados com base de tratamento aprovada","trimestral","Responsável de dados","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-015","Custo por formando","Despesa elegível efectivamente realizada / pessoas distintas atendidas; separar orçamento","moeda por pessoa","Execução financeira validada","por coorte","Gestor financeiro","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-016","Custo por pessoa empregada","Despesa elegível realizada / colocados confirmados; não inferir causalidade","moeda por pessoa","Execução financeira e Outcome","trimestral","Gestor financeiro","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-017","Investimento mobilizado","Montante contratualmente comprometido; desembolsado mostrado separadamente; moedas não somadas sem câmbio evidenciado","moeda","Acordos e comprovativos","mensal","Financiador","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-018","Equipamentos instalados","Unidades instaladas e aceites com evidência, por categoria/unidade; sem somar unidades heterogéneas","unidades por categoria","EquipmentNeed e aceitação","mensal","Instituição","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-019","Formadores capacitados","Pessoas distintas com conclusão confirmada em formação de formadores; não equivale a certificação","pessoas","CompletionRecord autorizado","trimestral","Instituição","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-020","Instituições apoiadas","Instituições canónicas com apoio entregue/aceite no período; propostas excluídas","instituições","Provider/Partnership/apoios comprovados","trimestral","Gestor","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-021","Empregadores participantes","Organizações distintas com procura, parceria ou absorção confirmada, segmentadas pelo tipo","organizações","Organization/LabourDemand/Partnership","trimestral","Secretariado","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-022","Lacunas encerradas","Snapshots cuja decisão de encerramento tem evidência suficiente; não contar revisões como novas lacunas","lacunas","SkillGap e ValidationDecision","mensal","Validador","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-023","Lacunas persistentes","Lacunas validadas ainda não cobertas à data necessária / lacunas elegíveis vencidas; sem dados não classificadas","percentagem","SkillGap e calendário","mensal","Analista","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-024","Satisfação do empregador","Soma das respostas válidas / respostas válidas na escala aprovada; taxa de resposta e viés explicitados","escala aprovada","Inquérito autorizado e anonimizado","por programa","Produto","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" +"IND-EDU-025","Qualidade e actualidade","Campos críticos válidos / críticos esperados; fontes dentro de validade / fontes usadas; duas medidas separadas","percentagem","DataSource/validações","semanal","Secretariado","Pendente de levantamento e validação pela entidade competente","Pendente de levantamento e validação pela entidade competente","Reconciliar fonte, período, população, método e decisão humana","Interno por defeito; público só agregado autorizado com limiar de supressão aprovado","Projecto;cluster;corredor;território;período;ocupação quando aplicável","Proposta" diff --git a/docs/investmoz/education/migration-proposal.md b/docs/investmoz/education/migration-proposal.md new file mode 100644 index 00000000..35bbc29e --- /dev/null +++ b/docs/investmoz/education/migration-proposal.md @@ -0,0 +1,59 @@ +# Proposta de migração Education + +> Actualização: o repositório já contém a migration de recepção interna `2026_09_26_100000_education_intake.php`, ensaiada numa base temporária nesta revisão. Ver [relatório actual](implementation-status.md). O inventário abaixo é histórico e não comprova o esquema nem as contagens da base actual da aplicação. + +Estado: **Proposta — Pendente de validação**. Este ficheiro não é uma migration executável. Não criar SQL activo para B02/B03 antes da decisão de modelo/identidade e autorização. Diagnóstico local read-only em 26/09/2026: as seis tabelas da migration Education existem e têm zero registos. Isso não prova ausência de fontes externas. + +## Inventário e mapeamento + +| Origem confirmada | Destino lógico | Transformação proposta | Rejeição/quarentena | +| ------------------------------------------------ | ------------------------------ | --------------------------------------------------------------------------- | ---------------------------------------------------- | +| skills.code/name | Skill canónica | Preservar UUID/código; sinónimos e versão em extensão | Código ambíguo, taxonomia desconhecida | +| occupations.code/name | Occupation | Preservar UUID; relacionar Skills por versão | Equivalência de nível sem prova | +| training_needs.quantity | Registo legado de necessidade | Manter o valor e a semântica original; só ligar a LabourDemand após revisão | Nunca assumir que quantity significa trabalhadores | +| training_needs.project_id/occupation_id/skill_id | Relações contextuais | Manter FKs; verificar consistência da cadeia | Órfão, projecto eliminado, fonte ausente | +| training_providers.organization_id/name/status | Provider canónico | Conservar candidate/approved/inactive | Nenhuma conversão approved → acreditada | +| courses.provider_id/code/title/status | Curso canónico | Manter UUID e vínculo; separar revisão curricular | Nenhuma conversão published → certificado | +| education_opportunities.course_id/datas/capacity | Oferta calendarizada existente | Preservar contrato actual | Não reutilizar como projecto de construção de centro | +| Documentos/fontes externos não inventariados | Área de recepção proposta | Fonte, proprietário, autorização, checksum, versão, unidade e data | Sem autorização não importar | + +## Vagas técnicas propostas + +| Vaga | Conteúdo lógico aditivo | Pré-condição | Reconciliação | Reversão | +| ---- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| M0 | Snapshot do catálogo, colunas, FKs, índices, triggers, consumidores e permissões | Inventário aprovado; backup base/documentos/configuração verificado | Contagens, hashes por conteúdo normalizado e manifesto | Nenhuma escrita | +| M1 | Extensões de fonte, validade e versão de taxonomia; relações ocupação–competência e curso–competência | Taxonomia/Shared confirmados | 100% UUID preservados, zero órfãos; decisão de unicidade | Desactivar leitores novos; conservar histórico | +| M2 | LabourDemand e relações contextuais, sem renomear training_needs | B02/B03 resolvidos | Quantidades/unidades/períodos iguais à fonte aprovada; rejeições explicadas | Parar escritores novos; não dropar dados | +| M3 | Capacidade por período/pool e snapshots SkillGap | Regras e fonte de disponibilidade aprovadas | Contagem de pools/alocações; zero dupla contagem | Reverter exposição e workers; snapshots preservados | +| M4 | Identidade de investimento educativo e vínculo a Opportunity | Decisão formal B02 | Cada oportunidade tem promotor, lacuna/versão e fonte; sem duplicação comercial | Despublicar e suspender encaminhamento | +| M5 | Projecções de leitura, contratos e eventos | Idempotência e mandatos testados | Reconciliar outbox/inbox e consumidor; zero estados oficiais promovidos | Feature flag desligada; drenar ou reter fila conforme plano | + +Nomes físicos novos, índices, rotas e policies são propostas, não factos confirmados. Antes de converter para Laravel migrations: revisar catálogo real completo e tipos, validar envelope, constraints e impacto nas queries/consumidores. Não acrescentar estado oficial a enum editorial. Preferir extensões opcionais inicialmente; backfill separado e validado antes de NOT NULL. A idempotência da criação estrutural usa o ledger de migrations; não esconder divergência com IF NOT EXISTS indiscriminado. + +## Pseudocódigo do importador, não executável + +```text +exigir fonte autorizada + versão do mapeamento aprovada + feature flag de ensaio +para cada lote: + verificar checksum, unidades, datas, direitos e chaves externas + normalizar apenas segundo taxonomia aprovada + rejeitar ambíguos para quarentena com motivo e referência, sem PII em logs + numa transacção: + procurar por sistema_origem + id_externo + versão + se versão igual e checksum igual: no-op + se versão igual e conteúdo diferente: conflito, não sobrescrever + se versão menor: rejeitar regressão + se versão maior: preservar snapshot e aplicar decisão de precedência + nunca copiar acreditação/certificação como confirmada sem decisão externa verificada + escrever auditoria e recibo do lote + emitir relatório de aceites/rejeitados/conflitos e totais por unidade/período +aprovar reconciliação antes de activar leitores públicos +``` + +## Ensaio, backup e aceitação + +Usar ambiente PostgreSQL/PostGIS isolado, primeiro dados sintéticos e depois cópia anonimizada autorizada. Zero dados pessoais reais em fixtures. Comparar antes/depois: contagens, somas por unidade e moeda, chaves, hashes normalizados, versões, relações, geometrias válidas, classificações e estados. Relatório inclui rejeições; não forçar sucesso descartando linhas silenciosamente. + +Backup consistente com manifestos de base/documentos/configuração; cifrado, acesso restrito, restauro verificado em ambiente separado. Critérios propostos: zero perda não autorizada, zero órfãos, zero duplicados de chaves, zero estados oficiais promovidos, totais reconciliados para cada lote aceite; divergências pendentes impedem activação do lote. Responsáveis e limites financeiros de reconciliação permanecem por aprovação. + +Rollback de aplicação começa pela flag, exposição e produtores/consumidores; não remove tabelas. Restauro físico só em incidente aprovado e depois de avaliar escritas posteriores ao backup. Eventos já entregues requerem compensação/reconciliação, não fingir que restaurar a base os desenvia. Remoções futuras exigem inventário, período de transição, aprovação e restauro ensaiado, numa entrega distinta. diff --git a/docs/investmoz/education/nonfunctional.csv b/docs/investmoz/education/nonfunctional.csv new file mode 100644 index 00000000..c14a680b --- /dev/null +++ b/docs/investmoz/education/nonfunctional.csv @@ -0,0 +1,19 @@ +"id","area","metrica","limiar_proposto","metodo_teste","responsavel","dependencias","fase","prioridade","estado" +"RNF-EDU-001","Segurança e segregação","Operações sem autorização e auto-aprovações","0 aceites; 100% operações sensíveis com teste negativo","Testes de policies por actor/organização/território, BOLA, mandato expirado e CSRF","Segurança","B03","F1","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-002","Privacidade e minimização","Dados pessoais expostos em público/CRM/logs indevidos","0 ocorrências; nenhuma localização individual publicada","Inspecção de payloads, logs, exportações, revogação e consentimento","Responsável de dados","B05","F1/F2","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-003","Desempenho","Latência p95 de consulta e escrita interactiva","Proposta: <=1s leitura e <=2s escrita a 50 utilizadores concorrentes em conjunto piloto de 100 mil registos sintéticos","Carga em ambiente dimensionado e aprovado; separar latência CRM/IA assíncrona","Operações","B08","F1","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-004","Disponibilidade","Percentagem de tempo de serviço mensal","Proposta 99,5%; manutenção e método de medição documentados","Monitor sintético e relatório mensal; ensaio de modo degradado","Operações","B08","F1","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-005","Escalabilidade","Consistência sob concorrência e backlog","0 duplicados em 100 reenvios concorrentes; proposta drenagem de 1000 eventos em <=15min sem dependência externa indisponível","Carga, falha do consumidor e retomada; recursos registados","Arquitectura","B04/B08","F1","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-006","Acessibilidade","Barreiras no percurso crítico","0 bloqueios de teclado; rótulos/erros legíveis; proposta WCAG 2.2 AA a validar com auditoria","Teclado, leitor de ecrã, contraste, zoom e verificação automatizada complementar","UX","Revisor de acessibilidade","F1","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-007","PT/EN","Cobertura de mensagens e estados críticos","100% fluxos críticos PT/EN; 0 chaves de tradução em bruto","E2E por idioma e revisão humana da terminologia","Produto","Glossário aprovado","F1","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-008","Responsividade","Overflow e perda de acções","0 overflow horizontal global a 360/390/768/1366px; tabelas com scroll local acessível","E2E com viewport e zoom; validação táctil","UX","I01–I07","F1","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-009","Interoperabilidade","Validade de contratos e tipos","100% exemplos conformes; datas ISO, UUID, moeda/país normalizados; 0 breaking changes não versionadas","Validador OpenAPI 3.1, contract tests e compatibilidade retroactiva","Arquitectura","B02/B03","F1/F3","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-010","Observabilidade","Operações correlacionadas sem segredos","100% mutações com correlation_id e actor; 0 tokens/PII nos eventos; proposta alerta em <=5min","Inspecção de logs/traces e injecção de falhas","Operações","B08","F1","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-011","Backup","Cobertura e recuperabilidade","Proposta backup diário cifrado; 100% recursos críticos incluídos; restauro trimestral verificado","Restauro base+documentos+configuração em ambiente isolado; contagens e hashes","Operações","Política de custódia","F1","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-012","Recuperação de desastre","RPO/RTO medidos","Proposta RPO <=24h e RTO <=8h; validar com impacto do negócio","Simulação de perda/recuperação e reconciliação da outbox","Operações","B08;backup","F1","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-013","Retenção e revogação","Cumprimento da política aprovada","100% classes com prazo/base/finalidade; revogação bloqueia novos acessos imediatamente; nenhum expurgo antes de aprovação","Teste de revogação, retenção legal e expurgo em cópia sintética","Responsável de dados","B05","F1/F2","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-014","Portabilidade","Exportação autorizada e reimportação","100% IDs/versionamento preservados e contagens reconciliadas; dados pessoais somente no escopo","Round-trip JSON/CSV com deduplicação e rejeições explícitas","Secretariado","R0/B05","F1","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-015","Manutenção","Qualidade e rastreabilidade das alterações","0 erros de análise estática; 100% regras críticas com testes; 0 dependências novas sem validação","CI, revisão por pares, regressão e análise de dependências","Equipa técnica","Branch e revisão","F1","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-016","Qualidade de dados","Completude/consistência dos campos críticos","100% obrigatórios nos registos publicados; 0 órfãos; 0 estados oficiais sem evidência; proposta >=90% completude global","Queries de reconciliação, validação de fontes e amostragem humana","Secretariado","B01/B06","F1","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-017","IA responsável","Decisões proibidas e rastreabilidade","0 atribuições de estados oficiais; 100% saídas com fonte/modelo/limites/revisão; métricas de utilidade/calibração pendentes do Comité","Red-team, dataset aprovado, testes de fuga, enviesamento e revisão","Comité IA e Dados","B07","F1 assistiva/F4","Must no âmbito activado","Proposta; limiar não aprovado nem medido" +"RNF-EDU-018","Concorrência e reversibilidade","Actualização perdida ou rollback destrutivo","0 lost updates; 412 em versão antiga; 0 remoções em migração inicial; restauração ensaiada","Concorrência If-Match, falha transaccional, rollback lógico e restauro","Arquitectura","B02/B03","F1","Must no âmbito activado","Proposta; limiar não aprovado nem medido" diff --git a/docs/investmoz/education/openapi.proposed.json b/docs/investmoz/education/openapi.proposed.json new file mode 100644 index 00000000..9cd2936c --- /dev/null +++ b/docs/investmoz/education/openapi.proposed.json @@ -0,0 +1,870 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "InvestMoz Education — contrato proposto do núcleo de procura", + "version": "0.1.0-proposal", + "description": "Não implementado. Bloqueios B01/B02/B03. Não usar para gerar clientes de produção. Demais endpoints na especificação estão pendentes de contratos detalhados." + }, + "x-artifact-status": "Proposta", + "x-authentication-pending": { + "target": "OAuth 2.0 e OpenID Connect", + "issuer": "Pendente de validação", + "scopes": "Propostos; não configurados", + "warning": "Bearer descreve transporte; não demonstra servidor OAuth/OIDC existente." + }, + "security": [ + { + "ProposedAccessToken": [] + } + ], + "paths": { + "/api/v1/education/labour-demands": { + "get": { + "operationId": "educationListLabourDemands", + "x-rf": ["RF-EDU-002", "RF-EDU-015"], + "x-required-scopes": ["education:read"], + "parameters": [ + { + "$ref": "#/components/parameters/Correlation" + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "maxLength": 2000 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "name": "territory_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Resultado proposto", + "headers": { + "X-Correlation-Id": { + "schema": { + "type": "string", + "format": "uuid" + } + }, + "ETag": { + "schema": { + "type": "string" + }, + "description": "ETag forte e opaco da versão" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DemandPage" + } + } + } + }, + "400": { + "$ref": "#/components/responses/Problem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "409": { + "$ref": "#/components/responses/Problem" + }, + "412": { + "$ref": "#/components/responses/Problem" + }, + "422": { + "$ref": "#/components/responses/Problem" + }, + "428": { + "$ref": "#/components/responses/Problem" + }, + "429": { + "$ref": "#/components/responses/Problem" + }, + "503": { + "$ref": "#/components/responses/Problem" + } + } + }, + "post": { + "operationId": "educationSubmitLabourDemand", + "x-rf": ["RF-EDU-002", "RF-EDU-009"], + "x-required-scopes": ["education:demand:submit"], + "parameters": [ + { + "$ref": "#/components/parameters/Correlation" + }, + { + "$ref": "#/components/parameters/Idempotency" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DemandInput" + } + } + } + }, + "responses": { + "201": { + "description": "Resultado proposto", + "headers": { + "X-Correlation-Id": { + "schema": { + "type": "string", + "format": "uuid" + } + }, + "ETag": { + "schema": { + "type": "string" + }, + "description": "ETag forte e opaco da versão" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Demand" + } + } + } + }, + "400": { + "$ref": "#/components/responses/Problem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "409": { + "$ref": "#/components/responses/Problem" + }, + "412": { + "$ref": "#/components/responses/Problem" + }, + "422": { + "$ref": "#/components/responses/Problem" + }, + "428": { + "$ref": "#/components/responses/Problem" + }, + "429": { + "$ref": "#/components/responses/Problem" + }, + "503": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/api/v1/education/labour-demands/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "get": { + "operationId": "educationGetLabourDemand", + "x-rf": ["RF-EDU-002"], + "x-required-scopes": ["education:read"], + "parameters": [ + { + "$ref": "#/components/parameters/Correlation" + } + ], + "responses": { + "200": { + "description": "Resultado proposto", + "headers": { + "X-Correlation-Id": { + "schema": { + "type": "string", + "format": "uuid" + } + }, + "ETag": { + "schema": { + "type": "string" + }, + "description": "ETag forte e opaco da versão" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Demand" + } + } + } + }, + "400": { + "$ref": "#/components/responses/Problem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "409": { + "$ref": "#/components/responses/Problem" + }, + "412": { + "$ref": "#/components/responses/Problem" + }, + "422": { + "$ref": "#/components/responses/Problem" + }, + "428": { + "$ref": "#/components/responses/Problem" + }, + "429": { + "$ref": "#/components/responses/Problem" + }, + "503": { + "$ref": "#/components/responses/Problem" + } + } + }, + "patch": { + "operationId": "educationReviseLabourDemand", + "x-rf": ["RF-EDU-009"], + "x-required-scopes": ["education:demand:submit"], + "description": "Alteração cria nova versão e invalida validação anterior; estado é gerido pelo servidor.", + "parameters": [ + { + "$ref": "#/components/parameters/Correlation" + }, + { + "$ref": "#/components/parameters/Match" + }, + { + "$ref": "#/components/parameters/Idempotency" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DemandPatch" + } + } + } + }, + "responses": { + "200": { + "description": "Resultado proposto", + "headers": { + "X-Correlation-Id": { + "schema": { + "type": "string", + "format": "uuid" + } + }, + "ETag": { + "schema": { + "type": "string" + }, + "description": "ETag forte e opaco da versão" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Demand" + } + } + } + }, + "400": { + "$ref": "#/components/responses/Problem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "409": { + "$ref": "#/components/responses/Problem" + }, + "412": { + "$ref": "#/components/responses/Problem" + }, + "422": { + "$ref": "#/components/responses/Problem" + }, + "428": { + "$ref": "#/components/responses/Problem" + }, + "429": { + "$ref": "#/components/responses/Problem" + }, + "503": { + "$ref": "#/components/responses/Problem" + } + } + } + }, + "/api/v1/education/labour-demands/{id}/decisions": { + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "post": { + "operationId": "educationDecideLabourDemand", + "x-rf": ["RF-EDU-009", "RF-EDU-032"], + "x-required-scopes": ["education:demand:review"], + "description": "Revisor distinto e mandato válido; não confere acreditação. Versão deve coincidir com If-Match.", + "parameters": [ + { + "$ref": "#/components/parameters/Correlation" + }, + { + "$ref": "#/components/parameters/Match" + }, + { + "$ref": "#/components/parameters/Idempotency" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecisionInput" + } + } + } + }, + "responses": { + "201": { + "description": "Resultado proposto", + "headers": { + "X-Correlation-Id": { + "schema": { + "type": "string", + "format": "uuid" + } + }, + "ETag": { + "schema": { + "type": "string" + }, + "description": "ETag forte e opaco da versão" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Demand" + } + } + } + }, + "400": { + "$ref": "#/components/responses/Problem" + }, + "401": { + "$ref": "#/components/responses/Problem" + }, + "403": { + "$ref": "#/components/responses/Problem" + }, + "404": { + "$ref": "#/components/responses/Problem" + }, + "409": { + "$ref": "#/components/responses/Problem" + }, + "412": { + "$ref": "#/components/responses/Problem" + }, + "422": { + "$ref": "#/components/responses/Problem" + }, + "428": { + "$ref": "#/components/responses/Problem" + }, + "429": { + "$ref": "#/components/responses/Problem" + }, + "503": { + "$ref": "#/components/responses/Problem" + } + } + } + } + }, + "components": { + "securitySchemes": { + "ProposedAccessToken": { + "type": "http", + "scheme": "bearer", + "description": "Proposta de access token validado; configuração OAuth/OIDC pendente." + } + }, + "parameters": { + "Id": { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + "Correlation": { + "name": "X-Correlation-Id", + "in": "header", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Servidor gera se ausente; nunca confere autorização." + }, + "Idempotency": { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Escopo actor + operação + recurso; payload diferente com mesma chave devolve 409." + }, + "Match": { + "name": "If-Match", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "ETag forte exacto; ausente 428, desactualizado 412; wildcard não aceite." + } + }, + "responses": { + "Problem": { + "description": "Erro sanitizado", + "headers": { + "X-Correlation-Id": { + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + }, + "schemas": { + "DemandInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "occupation_id": { + "type": "string", + "format": "uuid" + }, + "employer_id": { + "type": "string", + "format": "uuid" + }, + "territory_id": { + "type": "string", + "format": "uuid" + }, + "project_id": { + "type": "string", + "format": "uuid" + }, + "cluster_id": { + "type": "string", + "format": "uuid" + }, + "corridor_id": { + "type": "string", + "format": "uuid" + }, + "value_chain_id": { + "type": "string", + "format": "uuid" + }, + "qualification_level_id": { + "type": "string", + "format": "uuid" + }, + "skill_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "format": "uuid" + } + }, + "quantity": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "country_code": { + "type": "string", + "pattern": "^[A-Z]{2}$", + "description": "Validar pertença ISO 3166-1 alpha-2, não apenas regex." + }, + "activity": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "phase": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "workplace": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "nature": { + "type": "string", + "enum": ["permanent", "temporary", "seasonal"] + }, + "hiring_date": { + "type": "string", + "format": "date" + }, + "end_date": { + "type": ["string", "null"], + "format": "date" + }, + "source_id": { + "type": "string", + "format": "uuid" + }, + "reference_date": { + "type": "string", + "format": "date" + }, + "valid_until": { + "type": "string", + "format": "date" + }, + "evidence_document_id": { + "type": "string", + "format": "uuid" + }, + "classification": { + "type": "string", + "enum": ["internal", "confidential", "restricted"] + } + }, + "required": [ + "occupation_id", + "employer_id", + "territory_id", + "qualification_level_id", + "skill_ids", + "quantity", + "country_code", + "activity", + "phase", + "workplace", + "nature", + "hiring_date", + "source_id", + "reference_date", + "valid_until", + "evidence_document_id", + "classification" + ], + "anyOf": [ + { + "required": ["project_id"] + }, + { + "required": ["cluster_id"] + }, + { + "required": ["corridor_id"] + } + ], + "description": "Proposta; validar datas, mandato, contexto, fontes e taxonomias no serviço. Submissor não fornece estado validado." + }, + "DemandPatch": { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "properties": { + "occupation_id": { + "type": "string", + "format": "uuid" + }, + "employer_id": { + "type": "string", + "format": "uuid" + }, + "territory_id": { + "type": "string", + "format": "uuid" + }, + "project_id": { + "type": "string", + "format": "uuid" + }, + "cluster_id": { + "type": "string", + "format": "uuid" + }, + "corridor_id": { + "type": "string", + "format": "uuid" + }, + "value_chain_id": { + "type": "string", + "format": "uuid" + }, + "qualification_level_id": { + "type": "string", + "format": "uuid" + }, + "skill_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "format": "uuid" + } + }, + "quantity": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "country_code": { + "type": "string", + "pattern": "^[A-Z]{2}$", + "description": "Validar pertença ISO 3166-1 alpha-2, não apenas regex." + }, + "activity": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "phase": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "workplace": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "nature": { + "type": "string", + "enum": ["permanent", "temporary", "seasonal"] + }, + "hiring_date": { + "type": "string", + "format": "date" + }, + "end_date": { + "type": ["string", "null"], + "format": "date" + }, + "source_id": { + "type": "string", + "format": "uuid" + }, + "reference_date": { + "type": "string", + "format": "date" + }, + "valid_until": { + "type": "string", + "format": "date" + }, + "evidence_document_id": { + "type": "string", + "format": "uuid" + }, + "classification": { + "type": "string", + "enum": ["internal", "confidential", "restricted"] + } + }, + "description": "Validar o resultado completo após aplicar PATCH; não aceitar alteração de estado por este contrato." + }, + "Demand": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "state", "data", "updated_at"], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "version": { + "type": "integer", + "minimum": 1 + }, + "state": { + "type": "string", + "enum": [ + "submitted", + "in_review", + "validated", + "rejected", + "expired", + "suspended" + ] + }, + "data": { + "$ref": "#/components/schemas/DemandInput" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "DemandPage": { + "type": "object", + "additionalProperties": false, + "required": ["data", "next_cursor"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Demand" + } + }, + "next_cursor": { + "type": ["string", "null"] + } + } + }, + "DecisionInput": { + "type": "object", + "additionalProperties": false, + "required": [ + "decision", + "reason", + "evidence_document_id", + "authority_id" + ], + "properties": { + "decision": { + "type": "string", + "enum": ["validate", "reject", "return_for_correction"] + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "evidence_document_id": { + "type": "string", + "format": "uuid" + }, + "authority_id": { + "type": "string", + "format": "uuid" + } + } + }, + "Problem": { + "type": "object", + "required": ["type", "title", "status", "correlation_id"], + "properties": { + "type": { + "type": "string", + "format": "uri-reference" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer", + "minimum": 400, + "maximum": 599 + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string", + "format": "uri-reference" + }, + "correlation_id": { + "type": "string", + "format": "uuid" + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } +} diff --git a/docs/investmoz/education/requirements.csv b/docs/investmoz/education/requirements.csv new file mode 100644 index 00000000..09611c6a --- /dev/null +++ b/docs/investmoz/education/requirements.csv @@ -0,0 +1,34 @@ +"id","descricao","actor","pre_condicoes","fluxo_principal","dados_entidades","regra_negocio","criterio_aceitacao","fase","endpoint_proposto","evento_proposto","interface","risco","excepcoes","prioridade","dependencias","teste","gate","artefacto","estado" +"RF-EDU-001","Catálogo de competências","Secretariado","Taxonomia e fonte propostas","Criar código, sinónimos, nível e versão; rever antes de activar","Skill","RB01","Código/sinónimo duplicado ou versão activa alterada sem decisão é rejeitado","F1","/api/v1/education/skills","SkillCatalogueVersionProposed","I01","R05","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-001","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-002","Procura contextual de competências","Promotor","Contexto produtivo canónico e mandato","Registar ocupação, competências, quantidade, local, prazo e fonte","LabourDemand","RB03","Submissão sem fonte, local ou distinção estimativa/compromisso não é aceite","F1","/api/v1/education/labour-demands","LabourDemandSubmitted","I02","R02","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-002","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-003","Instituições, cursos e oferta","Instituição","Organização e representante verificados","Cadastrar instituição e curso com evidência, localização e capacidade","TrainingProvider;Course;TrainingCapacity","RB02","Publicação não atribui acreditação e identifica evidência em falta ou expirada","F1","/api/v1/education/providers;/api/v1/education/courses;/api/v1/education/capacities","TrainingCapacityUpdated","I03","R01","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-003","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-004","Comparação e lacunas","Analista","Fontes versionadas e comparáveis","Comparar procura e oferta; explicar unidades e exclusões","SkillGap","RB03;RB04;RB05;RB06;RB07;RB08","Vagas formativas nunca subtraídas como pessoas; desconhecido não vira zero","F1","/api/v1/education/gaps","SkillGapDetected","I04","R02","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-004","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-005","Oportunidade educativa","Promotor","Lacuna validada; B02 resolvido","Preparar problema, solução, recursos, orçamento indicativo, riscos e CTA","EducationInvestmentOpportunity","RB12","Não publicar sem promotor, território, beneficiários, modelo financeiro e autorização","F1","/api/v1/education/investment-opportunities","EducationInvestmentOpportunityCreated","I05","R06","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-005","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-006","Navegação contextual","Investidor","Fichas publicadas e relações autorizadas","Navegar entre oportunidade, projecto, cluster, corredor, elo e competência","Project;Cluster;Corridor;ValueChain","RB16","Contexto preservado na manifestação; relação duplicada ou alvo inacessível é bloqueado","F1","/api/v1/education/investment-opportunities/{id}","EducationContextLinked","I05","R03","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-006","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-007","Acreditação reportada","Verificador mandatado","Entidade competente e evidência externa","Registar declaração, verificar referência, escopo e validade","AccreditationRecord","RB01;RB02;RB11","Gestor não confirma acreditação; expiração não aparece como válida; documento restrito protegido","F1 declaração;F3 integração","/api/v1/education/providers/{id}/accreditation-evidence","AccreditationStatusReceived","I06","R01","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-007","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-008","Manifestação de interesse","Investidor/financiador/fornecedor","Contacto verificado e finalidade consentida","Submeter papel, identidade e contexto; devolver protocolo","Investor;Contact;EducationInvestmentOpportunity","RB13;RB15;RB16","Reenvio igual não duplica; sem consentimento/escopo não transmite contacto","F1","/api/v1/education/investment-opportunities/{id}/interests","InvestorInterestRegistered;FunderInterestRegistered","I05","R04","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-008","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-009","Declaração do empregador","Empregador","Mandato válido","Versionar procura com local, ocupação, prazo e estágio; revisão independente","LabourDemand;ValidationDecision","RB01;RB17","Alterar versão confirmada exige nova revisão; dados confidenciais não publicados","F1","/api/v1/education/labour-demands/{id}/decisions","LabourDemandValidated","I02;I06","R03","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-009","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-010","Resposta formativa","Instituição","Lacuna validada e competências-alvo","Propor programa, currículo referenciado, recursos e calendário","TrainingProgramme;Curriculum","RB03;RB15","Incompatibilidade curricular/capacidade é assinalada; proposta não vira aprovação oficial","F1 proposta;F2 execução","/api/v1/education/responses","TrainingResponseProposed","I04","R01","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-010","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-011","Correspondência explicável","Analista","Critérios e fontes autorizadas","Ordenar candidatos de oferta com motivos, limitações, confiança e revisão","TrainingProvider;SkillGap","RB03;RB10","Condição eliminatória bloqueia encaminhamento; recomendação nunca garante resposta","F1 assistida;F2 avançada","/api/v1/education/matches","TrainingProviderMatched","I04","R05","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-011","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-012","Resultados educativos","Instituição","Turma, consentimento e evidência F2","Reportar vagas, matrículas, conclusão, certificação externa e colocação","TrainingCohort;CompletionRecord;EmploymentOutcome","RB17","Não reportar emprego sem período, empregador e evidência; conclusão ≠ certificação","F2","/api/v1/education/cohorts/{id}/outcomes","TrainingCompleted;EmploymentOutcomeReported;CertificationReported","I08","R03","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Should após MVP","B01/B03/B05; dependências específicas nas secções 2 e 23","T-012","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-013","Bolsas e campanhas","Financiador","Critérios e orçamento aprovados","Relacionar bolsa/campanha com programa, território e financiador","Scholarship;Funder","RB15","Reserva não excede orçamento; elegibilidade final é humana","F2","/api/v1/education/scholarships","ScholarshipProposed","I08","R07","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Should após MVP","B01/B03/B05; dependências específicas nas secções 2 e 23","T-013","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-014","IA supervisionada","Analista e revisor","B07 e conjunto de avaliação aprovados","Propor lacuna/recomendação com dados, modelo, horizonte e limitações","SkillGap;ValidationDecision","RB10;RB17","Sem decisão institucional/laboral automática; modelo não pode publicar","F1 assistiva;F4 previsão","/api/v1/education/ai-recommendations","EducationRecommendationProposed","I06","R05","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-014","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-015","Painel e pesquisa","Gestor/analista","Agregação e fontes autorizadas","Filtrar projecto, cluster, corredor, território, competência e período","SkillGap;TrainingCapacity","RB03;RB16","Filtros não misturam períodos; exportação não expõe dados pessoais ou células proibidas","F1","/api/v1/education/indicators","EducationIndicatorsRefreshed","I07","R03","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-015","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-016","APIs e eventos","Integrador autorizado","B03/B04 e contratos aprovados","Aplicar scopes, cursor, correlação, idempotência, versão e replay controlado","AuditEvent;DataSource","RB13;RB14;RB16","Duplicação, token sem escopo e payload incompatível têm testes negativos","F1 interno;F3 externo","/api/v1/education/labour-demands","EducationIntegrationReceiptRecorded","I06","R04","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-016","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-017","Perfis ocupacionais e níveis","Secretariado","Quadro de qualificações identificado","Relacionar ocupação, competências e proficiência por versão","Occupation;QualificationLevel;Skill","RB03;RB04","Níveis não equivalentes não são combinados; sinónimos não multiplicam postos","F1","/api/v1/education/occupations","OccupationProfileVersionProposed","I01","R02","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-017","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-018","Previsão temporal e território","Empregador/coordenador","Calendário e local evidenciados","Registar permanência/sazonalidade, data necessária, duração e fase","LabourDemand;Territory","RB03;RB07","Oferta posterior à contratação não cobre lacuna na janela; sem geometria não inventa ponto","F1 declaração;F4 previsão","/api/v1/education/labour-demands","LabourDemandSubmitted","I02","R05","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-018","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-019","Capacidade institucional","Instituição","Curso, recursos, período e pool conhecidos","Declarar total/utilização, formadores, equipamentos e instalações","TrainingCapacity;Facility","RB06;RB08","Vagas usadas <= total; capacidade partilhada não alocada duas vezes","F1","/api/v1/education/capacities","TrainingCapacityUpdated","I03","R02","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-019","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-020","Validação e actualização da lacuna","Validador independente","Snapshot e método disponíveis","Confirmar tipo/evidência; reduzir, encerrar ou reabrir com nova versão","SkillGap;ValidationDecision","RB01;RB11;RB17","Fonte expirada invalida cálculo corrente; encerramento sem resultado comprovado bloqueado","F1 revisão;F2 resultados","/api/v1/education/gaps/{id}/decisions","SkillGapValidated;SkillGapReduced;SkillGapReopened","I04;I06","R05","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-020","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-021","Encaminhamento ao CRM","Gestor CRM","Interesse consentido e dono comercial definido","Associar interessado, contexto, responsável, SLA e próxima acção","Investor;Contact;Opportunity","RB13;RB15;RB16","Falha remota não perde pedido; nenhum dado académico enviado; identidade não duplicada","F1 assistido;F3 sincronizado","/api/v1/education/investment-opportunities/{id}/interests","InvestorInterestRegistered","I05","R04","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-021","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-022","Equipamentos e instalações","Instituição","Resposta a lacuna validada","Inventariar recursos necessários e estimativa com fonte e unidade","EquipmentNeed;Facility;Laboratory","RB06;RB12","Recurso adquirido não marcado como instalado sem evidência; custo desconhecido permanece null","F1 necessidade;F2 execução","/api/v1/education/equipment-needs","EquipmentNeedProposed","I04","R02","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-022","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-023","Formadores e desenvolvimento","Instituição","Competências do programa e contagens autorizadas","Quantificar horas/formadores necessários; planear formação de formadores","Trainer;TrainingProgramme","RB06;RB16","MVP só agregados; nenhuma inferência de competência pessoal","F1 necessidade;F2 pessoas","/api/v1/education/responses","TrainerCapacityNeedProposed","I04","R03","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-023","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-024","Financiamento e sustentabilidade","Promotor/financiador","Orçamento com fonte e moeda","Separar CAPEX/OPEX, bolsas, défice e instrumentos por fase","FundingNeed;EducationInvestmentOpportunity","RB12;RB15","Moedas não somadas sem conversão evidenciada; interesse não é compromisso","F1 estimativa;F2 compromissos","/api/v1/education/funding-needs","EducationFundingNeedProposed","I05","R07","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-024","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-025","Parcerias instituição–empregador","Instituição e empregador","Mandatos e acordo documentado","Registar compromissos, duração, responsabilidades e validade","Partnership","RB01;RB15","Sem acordo validado não aparece parceria formal activa","F2","/api/v1/education/partnerships","EmployerPartnershipCreated","I08","R01","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Should após MVP","B01/B03/B05; dependências específicas nas secções 2 e 23","T-025","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-026","Estágios e aprendizagem","Instituição e empregador","Acordo, supervisor e autorização do formando","Relacionar percurso, empregador, calendário e avaliação","Internship;Apprenticeship","RB16;RB17","Estágio não contado como emprego; acesso externo não autorizado negado","F2","/api/v1/education/placements","InternshipStarted","I08","R03","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Should após MVP","B01/B03/B05; dependências específicas nas secções 2 e 23","T-026","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-027","Turmas e beneficiários","Instituição","B05 resolvido; programa autorizado","Inscrever, reservar vagas e acompanhar percurso individual","TrainingCohort;Learner","RB08;RB16","Pessoa/turma não duplica; matrícula não certifica nem ocupa vaga noutro pool silenciosamente","F2","/api/v1/education/cohorts","TrainingCohortStarted","I08","R03","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Should após MVP","B01/B03/B05; dependências específicas nas secções 2 e 23","T-027","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-028","Conclusão e certificação externa","Instituição e emissor competente","Evidência e consentimento","Separar conclusão reportada de certificação externa verificada","CompletionRecord;CertificationRecord","RB02;RB16","Portal não emite diplomas; evidência inválida impede confirmado","F2/F3","/api/v1/education/cohorts/{id}/outcomes","TrainingCompleted;CertificationReported","I08","R01","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Should após MVP","B01/B03/B05; dependências específicas nas secções 2 e 23","T-028","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-029","Inserção e absorção","Empregador","Consentimento e vínculo evidenciado","Confirmar ocupação, início e projecto de absorção","EmploymentOutcome","RB16;RB17","Ausência de resposta não é desemprego; mesma pessoa deduplicada no indicador","F2","/api/v1/education/employment-outcomes","EmploymentOutcomeReported","I08","R03","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Should após MVP","B01/B03/B05; dependências específicas nas secções 2 e 23","T-029","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-030","Retenção profissional","Empregador e titular","Janela de seguimento e população elegível","Registar observação aos intervalos aprovados e não respondentes","EmploymentOutcome","RB17","Denominador elegível explícito; perdas de seguimento apresentadas separadamente","F2","/api/v1/education/employment-outcomes/{id}/observations","EmploymentOutcomeReported","I08","R05","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Should após MVP","B01/B03/B05; dependências específicas nas secções 2 e 23","T-030","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-031","GIS e relatórios","Analista/gestor","Camadas, método e divulgação aprovados","Comparar cobertura e distâncias; exportar agregados reconciliados","Territory;SkillGap;TrainingProvider","RB03;RB16","Distância linear rotulada; residências individuais e células pequenas não expostas","F1","/api/v1/education/map;/api/v1/education/indicators","EducationReportPrepared","I07","R03","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-031","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-032","Governação e auditoria","Validador/auditor","Mandatos e E0","Registar decisões, rejeição, revisão, suspensão e rastreabilidade","ValidationDecision;AuditEvent","RB01;RB02;RB17","Quem recomenda não aprova; trilha imutável sem dados pessoais em payload","F1","/api/v1/education/audit;/api/v1/education/labour-demands/{id}/decisions","DataValidationExpired","I06","R01","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-032","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" +"RF-EDU-033","Qualidade, migração e validade","Secretariado/Operações","Fontes inventariadas e plano de restauro","Quarentenar inconsistências, reconciliar versões e alertar expiração","DataSource;ValidationDecision","RB11;RB14;RB17","Zero registos órfãos/estados oficiais promovidos sem evidência; replay não apaga dados","F0/F1","/api/v1/education/data-quality","DataValidationExpired","I06","R08","Fonte expirada, autorização insuficiente, conflito de versão ou unidade: rejeitar/suspender com motivo","Must no âmbito da fase","B01/B03/B05; dependências específicas nas secções 2 e 23","T-033","G0/G1/G2; G3 para expansão","§23: pacotes e caminhos propostos","Proposta" diff --git a/docs/investmoz/education/specification.md b/docs/investmoz/education/specification.md new file mode 100644 index 00000000..d1206663 --- /dev/null +++ b/docs/investmoz/education/specification.md @@ -0,0 +1,435 @@ +# Formação Técnica, Competências e Investimento em Educação + +> O diagnóstico inicial abaixo foi complementado pelo [relatório de execução](implementation-status.md), que identifica a implementação interna existente, as correcções verificadas e as lacunas por RF. A presença de código não resolve automaticamente B01–B08 nem aprova esta proposta. + +Versão 0.1 — 26/09/2026 — **Proposta**. Nenhum artefacto deste pacote está aprovado para produção, homologação ou integração automática. Pedido de origem: anexo `1b36f6f0-93cf-49aa-9479-986a57bd18f4/Pasted text.txt`. Os exemplos não activam funcionalidades nem alteram dados do portal. + +## 1. Sumário executivo + +O submódulo liga necessidades produtivas a respostas formativas e investimento educativo. Não é formação sobre o uso do portal. A unidade de análise é a procura de trabalhadores por ocupação, qualificação, território e período, apoiada por competências verificáveis e por oferta formativa cuja capacidade, calendário e situação institucional estejam documentados. + +Percurso: **projecto/cluster/corredor → procura de mão-de-obra → competências → lacunas → resposta formativa → oportunidade de investimento educativo → formação → inserção → monitoria**. + +O MVP proposto limita a activação a até três clusters e três corredores, projectos-piloto validados e dados institucionais autorizados. A arquitectura permite expansão. Os cadastros documentais existentes não constituem aprovação dos pilotos. Dados de procura, instituições, cursos, custos, acreditações, capacidade e resultados: **Pendente de levantamento e validação pela entidade competente**. + +O pacote inclui esta especificação, catálogo de 44 entidades, matrizes RF/RNF e indicadores, contrato OpenAPI proposto, proposta de migração, exemplos de cálculo e testes de especificação. Estes últimos verificam regras ilustrativas, não a implementação do submódulo no portal. + +## 2. Escopo, diagnóstico e decisões + +### 2.1 Âmbito do MVP + +Registar procura e oferta; versionar fontes; confrontar unidades comparáveis; rever lacunas; preparar oportunidades educativas; receber interesse com consentimento e encaminhamento assistido ao CRM; pesquisar e apresentar indicadores agregados e mapa autorizado; emitir sugestões assistivas sujeitas a revisão. Instituições seleccionadas e profissões críticas dependem do levantamento. Formação, financiamento executado, turmas, bolsas, estágios, inserção e retenção constituem a extensão F2, com desenho antecipado sem recolher já os respectivos dados pessoais. + +Exclusões: acreditar, certificar, emitir diplomas, gerir integralmente a vida académica ou salários, seleccionar ou contratar automaticamente pessoas, conceder financiamento automaticamente, classificar publicamente instituições por algoritmo, produzir previsões vinculativas ou integrar instituições sem acordo. + +### 2.2 Evidência do repositório e reutilização + +| Componente | Evidência verificada | Decisão | Qualidade, dependência, risco e esforço | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| React/Inertia/Laravel | lockfiles: React 19.2.8, Inertia React 3.7.0, Laravel 13.30.1, Inertia Laravel 3.3.3, Fortify 1.39.0 | Conservar | Convenções confirmadas; criar funcionalidades no mesmo produto; esforço baixo de integração visual | +| PostgreSQL/PostGIS | Consulta local: PostgreSQL 17.11, PostGIS 3.6.2 | Conservar | Read-only nesta entrega; sem base paralela | +| Skills e Occupations | migration `2026_09_24_009000`; zero registos em ambas | Melhorar | Cadastros mínimos; faltam taxonomia, sinónimos, níveis e versões aprovadas; esforço médio | +| training_needs | project/occupation/skill/quantity/priority; zero registos | Melhorar por extensão compatível | Não equivale ainda a LabourDemand; não reinterpretar quantity como trabalhadores sem evidência; esforço alto | +| training_providers e courses | organization/provider; estados simples; zero registos | Conservar e estender | `approved` não significa acreditação; capacidades e decisões são lacunas funcionais | +| education_opportunities | course_id obrigatório, datas e capacity; zero registos | Conservar como oferta calendarizada | Não suporta centro novo sem curso. B02 impede substituir silenciosamente a semântica | +| Projectos, clusters e corredores | cadastros, ligações, carteira de 29 rascunhos e jornada implementados | Conservar | Reutilizar UUID; publicações e contexto não equivalem a pilotos institucionais aprovados | +| Autenticação e autorização | Fortify; gate `manage-portal` por emails verificados | Melhorar | Não há mandato institucional por organização; não habilitar acreditação usando o gate actual | +| Documentos/consentimentos/auditoria | tabelas Shared, education_documents, PortalAudit | Conservar e completar | Existência de tabelas não demonstra cofre, antivírus, revogação granular ou retenção operacionais | +| CRM local e SuiteCRM | conversão assistida, cliente OAuth, outbox; CRM externo sem instalação acessível | Conservar transição | Sem escrita dupla; sincronização real bloqueada; esforço alto de homologação | +| API pública actual | `routes/api.php`: catálogo e interesse, throttling e contratos existentes | Conservar | Sem OAuth/OIDC de acesso à API Education demonstrado; não trocar contratos públicos | +| GIS | geo_locations/geo_geometries, SRID 4326, validadores | Conservar e melhorar | Sem mapa institucional autorizado ou motor de rotas confirmado | +| Componentes a substituir/descontinuar | Nenhum demonstrado como dispensável | Nenhuma remoção | Evitar inventar dívida técnica ou destruir investimentos existentes | + +Não foi encontrada pasta `.git` acessível na raiz durante o diagnóstico. Antes de código de integração: confirmar checkout/branch própria e revisão por pares. Esta entrega é documentação e exemplos isolados, sem migrations executáveis em `database/migrations`. + +### 2.3 Bloqueios e pressupostos + +| ID | Bloqueio / decisão necessária | Responsável proposto | Efeito | +| --- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------- | +| B01 | Pilotos, entidades competentes, fontes e mandatos não identificados | Patrocinador + Secretariado de Dados | Não activar nem publicar dados oficiais | +| B02 | Oferta calendarizada ≠ oportunidade de investimento educativo; decidir extensão do catálogo Opportunity e identidade UUID | Produto + Arquitectura | Apenas modelo/migration proposta; preservar `course_id` e contratos actuais | +| B03 | RBAC por organização, segregação e OAuth/OIDC Education não implementados | Arquitectura + Segurança | Endpoints e policies apenas propostos | +| B04 | SuiteCRM sem motor/instalação/OAuth operacional | Operações + responsável CRM | Captura local e desenho de outbox; sem sincronização declarada concluída | +| B05 | Base de tratamento, retenção, menores, partilha e supressão estatística não aprovadas | Responsável de dados + jurídico | Sem recolha de beneficiários; sem exportações pessoais | +| B06 | Taxonomias, conversões de níveis, cobertura e validade das fontes não aprovadas | Entidades competentes | Cálculos oficiais indisponíveis; casos sintéticos apenas | +| B07 | Fornecedor/modelo IA, fontes e métricas não aprovados | Comité de IA e Dados | IA desligada; não apresentar regras determinísticas como IA | +| B08 | Limiares operacionais, recursos, orçamento e pilotos de carga não aprovados | Produto + Operações | Metas RNF são propostas; não compromisso de disponibilidade | + +Precedência: protecção de dados e limites institucionais; não destruição; compatibilidade; revisão humana; reversibilidade; MVP. Conflitos B01–B08 impedem código executável para os itens afectados. Nenhuma lei, autoridade, instituição ou classificação nacional é presumida neste documento. + +## 3. Actores e responsabilidades + +| Grupo | Actores | Responsabilidade | +| ------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Procura | Promotor, empregador, coordenador de cluster/corredor | Declarar postos, competências, calendário, local, evidência e responsável | +| Oferta | Instituição, prestador, formador | Declarar programas, capacidade, recursos e limitações; formador não certifica por ser utilizador | +| Regulação | Tutela, acreditadora/certificadora competente | Confirmar decisões exclusivamente no mandato e escopo autorizados | +| Investimento | Investidor, financiador, fornecedor | Consultar fichas aprovadas; expressar interesse; assumir compromissos formalizados | +| Beneficiários | Formando, trabalhador, candidato | Controlar dados autorizados; consultar percurso próprio; contestar informação | +| Operação | Gestor, analista, administrador, auditor | Tramitar, calcular, operar e auditar com funções separadas | +| Governação | Patrocinador, Produto, comités, Secretariado | Aprovar escopo, fontes, arquitectura, critérios e passagem de fase | +| Público | Visitante | Ler apenas projecções publicadas e agregadas | + +## 4. Arquitectura funcional + +| Blocos | MVP e evolução | Reutilização | +| ------------------------------------------------------ | --------------------------------------------------------- | ---------------------------------------------------------- | +| 1 Observatório; 2 Procura; 3 Ocupações; 4 Competências | F1: registo, taxonomias versionadas, consulta | skills, occupations, project/cluster/corridor | +| 5 Instituições; 6 Cursos; 7 Capacidade | F1: cadastro e evidências sem atribuir acreditação | training_providers, courses, organizations, documents | +| 8 Lacunas; 9 Respostas; 10 Investimento educativo | F1: cálculo assistido, parecer humano e ficha | serviço de cálculo proposto, catálogo Opportunity após B02 | +| 11 Financiamento/bolsas; 12 Equipamento; 13 Parcerias | F1: necessidades indicativas; F2: compromissos e execução | CRM e contrapartes canónicas | +| 14 Aprendizagem/estágios; 15 Formadores | F2: acordos e percursos; F1 só requisitos agregados | identidade e documentos Shared | +| 16 Beneficiários; 17 Inserção; 18 Resultados | F2; indicadores preparados em F1 sem inventar dados | consentimento, auditoria e estatística agregada | +| 19 IA; 20 Governação | F1 assistiva após B07; validação humana transversal | aprovações e observabilidade partilhadas | + +React apresenta projecções autorizadas. Laravel concentra regras, autorização contextual, validação, transacções e contratos. PostgreSQL/PostGIS conserva dados canónicos e relações. Jobs só processam eventos confirmados; nenhum HTTP externo dentro da transacção de negócio. Não instalar nova base, framework ou serviço complementar nesta fase. + +## 5. Jornada completa + +| Passos | Operação e evidência | Resultado / excepção | +| ------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| 1–3 | Empregador declara procura com contexto, ocupação, competências, quantidade e prazo | Submetida; falta de fonte ou unidade devolve para correcção | +| 4 | Validador mandatado, diferente do submissor, analisa a versão | Validada, rejeitada ou devolvida; alteração posterior invalida decisão da versão anterior | +| 5–8 | Analista selecciona oferta comparável, origem, período e método | Lacuna com fontes, tipo, confiança qualitativa e limitações; desconhecidos não viram zero | +| 9–10 | Correspondência sugere instituições e recursos necessários | Lista justificada; incompatibilidade curricular/calendário explicitada; sem promessa | +| 11 | Promotor converte lacuna validada em ficha educativa | Rascunho; publicação exige revisão, promotor, riscos, plano e autorização | +| 12–13 | Interesse consentido, identidade, papel e contexto → CRM | Protocolo idempotente, responsável e próxima acção; falha externa preserva pedido e tarefa manual | +| 14 | Autoridade recebe pedido curricular quando aplicável | Apenas decisão externa recebida, nunca aprovação emitida pelo portal | +| 15–16 | Instituição executa; reporta inscrições/conclusão e certificação externa | F2; separar conclusão de certificação, manter evidências e acesso restrito | +| 17 | Empregador confirma absorção autorizada | Vínculo e data, sem inferir emprego por matrícula ou ausência de resposta | +| 18–20 | Recalcular lacuna, detectar expiração/divergência, auditar | Nova versão; reabrir se necessário; nunca reescrever retrospectivamente resultados | + +## 6. Diagrama de componentes + +```mermaid +flowchart LR + UI[React: Education] --> BFF[Laravel: autenticação e autorização] + BFF --> EDU[Serviços Education propostos] + EDU --> SH[Shared: identidade, documentos, consentimento, auditoria] + EDU --> DATA[(PostgreSQL / PostGIS)] + EDU --> OUT[Outbox existente] + OUT --> JOB[Worker proposto e reconciliação] + JOB --> CRM[SuiteCRM: futuro mestre comercial] + EDU --> REVIEW[Revisão humana mandatada] + AI[IA assistiva condicionada] --> REVIEW + SOURCES[Fontes autorizadas] --> EDU +``` + +## 7. Diagrama de sequência + +```mermaid +sequenceDiagram + participant E as Empregador + participant P as Portal Laravel + participant D as PostgreSQL + participant V as Validador + participant A as Analista + participant C as CRM + E->>P: Submeter procura com versão e idempotência + P->>D: Procura + auditoria + outbox na mesma transacção + P-->>E: Protocolo e estado submetida + V->>P: Rever versão e evidência (mandato válido) + P->>D: Decisão segregada e evento + A->>P: Comparar fontes, período, unidade e nível + P-->>A: Lacuna explicada ou dados insuficientes + V->>P: Validar lacuna e proposta educativa + P->>D: Ficha em rascunho e revisão de publicação + E->>P: Manifestar interesse consentido + P->>D: Pedido, recibo, contexto e evento + Note over P,C: Integração real depende de B04 + P->>C: Envio assíncrono mínimo após activação validada + C-->>P: Identificador e confirmação reconciliável +``` + +## 8. Catálogo de entidades + +O catálogo completo está em [entities.csv](entities.csv), incluindo finalidade/domínio, campos tipados, obrigatoriedade, relações, estados, regras, fonte/responsável, fase, classificação, validade, histórico, permissões e retenção. `!` obrigatório, `?` opcional. Campos novos são propostas, mesmo quando associados a tabela existente. + +**Envelope E0 proposto:** UUID canónico; source_system/external_id; version_no positivo; classificação; correlation_id; data de referência e validade; responsável pelos dados; fonte/evidência; autor da submissão; estado de revisão; datas de criação/alteração. Usar o envelope existente quando presente; novos atributos exigem análise de compatibilidade. FK para `users` é bigint no repositório, não UUID inventado. Os restantes IDs devem respeitar o tipo real do domínio: catálogo público Opportunity usa bigint, carteira e domínio CRM usam UUID. + +**Retenção R0:** cada classe terá finalidade, fundamento, prazo/evento de início, prazo de revisão, retenção legal, anonimização e destino aprovados. Até B05: não recolher dados pessoais do novo domínio e não activar expurgo. Não adoptar retenção infinita como política final. Dados demonstrativos sintéticos podem ser descartados apenas no ambiente isolado previsto para ensaios. + +## 9. Modelo lógico de dados + +Procura é um lote de postos de trabalho, não uma lista de pessoas. Competências são requisitos N:M desse lote. Uma ocupação pode requerer várias competências sem multiplicar a quantidade de trabalhadores. Uma oferta formativa liga instituição, curso/versão curricular, capacidade por período e território. A relação ocupação–competência e curso–competência terá proficiência, evidência, versão e validade. + +SkillGap referencia snapshots de procura e oferta, método, pressupostos e decisões; não duplica as entidades originais. EducationInvestmentOpportunity é um projecto de resposta a uma lacuna validada; pode existir antes de um curso. A oferta calendarizada `education_opportunities` continua com o significado actual. B02 requer decidir o vínculo à oportunidade pública e ao negócio comercial, sem impor `course_id` a uma nova escola. + +Para estimar recursos: custo monetário decimal, moeda obrigatória com valor, fonte/data e natureza estimada ou contratual. Null significa desconhecido. Nunca converter ausência de orçamento em zero. Quantidade usa inteiro não negativo, unidade e período explícitos; procura activa exige quantidade positiva. Dados legados ficam `não avaliados`, nunca automaticamente validados. + +## 10. Matriz de relacionamentos + +| Relação | Cardinalidade | Regra | +| ------------------------------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------- | +| Project/Cluster/Corridor ↔ LabourDemand | N:M contextual | Pelo menos um contexto produtivo; contexto autorizado; relações entre contextos verificadas | +| Territory → LabourDemand / Provider / Capacity | 1:N | Local canónico; localização pessoal não pública | +| Occupation ↔ Skill ↔ Course | N:M / N:M | Nível, versão, equivalência e evidência; sem duplicar a procura por competência | +| TrainingProvider → Course → TrainingCapacity | 1:N / 1:N | Capacidade por janela, local, modalidade e pool partilhado | +| LabourDemand / Capacity → SkillGap | N:M por snapshot | Snapshot imutável e método reproduzível | +| SkillGap → Programme / EducationInvestmentOpportunity | 1:N / 1:N | Lacuna validada; resposta parcial permitida com saldo conhecido | +| Programme ↔ Funder / Investor / EquipmentSupplier | N:M | Papel e compromisso; identidades Shared; manifestação não é financiamento | +| Provider ↔ Employer | N:M via Partnership | Mandatos, acordo, validade e confidencialidade | +| Programme → Cohort → Learner | 1:N / N:M | F2, inscrição autorizada e deduplicada | +| Learner → Internship / Apprenticeship / Completion / Outcome | 1:N | Consentimento e escopo por finalidade; um resultado pode ter várias observações | +| Outcome → Project/Cluster/Corridor | N:M contextual | Absorção confirmada distinta de atribuição causal | +| Documento/Fonte/Decisão → versão de qualquer entidade | N:M | Autor da submissão não confirma sozinho; evidência restrita não é pública | + +Exclusões físicas seguem integridade referencial e retenção aprovada; eliminar um registo não pode apagar decisões ou alterar cálculos históricos. + +## 11. Requisitos RF-EDU e RNF-EDU + +[requirements.csv](requirements.csv) preserva o significado dos RF-EDU-001–016 da auditoria v4 e acrescenta RF-EDU-017 em diante. Cada linha possui actor, pré-condições, fluxo, excepções, dados, regra, aceitação, prioridade, dependência, fase e teste, mais referências aos contratos, evento, interface, risco e gate. Novos IDs não renumeram os existentes. + +[nonfunctional.csv](nonfunctional.csv) define métricas e limiares **propostos**, método de teste e responsável. Aprovação dos limiares é B08. Ter uma matriz preenchida não significa que segurança, desempenho ou disponibilidade foram testados. + +## 12. Regras de negócio e cálculo de lacunas + +| ID | Regra verificável | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| RB01 | Submissor e validador não coincidem; mandato/território/entidade devem estar válidos no instante da decisão | +| RB02 | `approved` de provider e `published` de curso são estados existentes; nunca convertê-los em acreditação/certificação | +| RB03 | Comparação usa mesma ocupação, nível ou equivalência aprovada, território/cobertura, período e unidade; incompatíveis são excluídos com motivo | +| RB04 | Procura D soma lotes distintos de postos, deduplicados por empregador/projecto/fase/ocupação/local/janela; não somar a mesma pessoa por cada competência | +| RB05 | Oferta S de trabalhadores exige disponibilidade e evidência autorizada, deduplicada e alocada; vagas de curso não são S. Lacuna observada G=max(0,D−S) só com D e S conhecidos | +| RB06 | Capacidade formativa F é min(vagas livres, capacidade de formadores, equipamentos e instalações), com unidades comparáveis. Falta de uma capacidade crítica torna F não determinada | +| RB07 | Pipeline P=F×taxa de conclusão×taxa de elegibilidade×taxa de disponibilidade só é estimativa, com taxas documentadas, janela de conclusão anterior à contratação e intervalo de incerteza; sem taxas não calcular P | +| RB08 | Lacuna projectada max(0,D−S−P_alocado) é separada da observada. Livro de alocação impede usar a mesma oferta em vários projectos ou cursos do mesmo pool | +| RB09 | Lacuna qualitativa lista competências/proficiências/requisitos em falta; não transformar texto em número de pessoas nem score sem metodologia aprovada | +| RB10 | Confiança usa evidência, actualidade, cobertura e validação; desconhecido é desconhecido. Não inventar percentagem probabilística de confiança | +| RB11 | Fonte expirada bloqueia nova publicação e correspondência oficial; invalida projecções dependentes, gera alerta e preserva versões históricas | +| RB12 | Lacuna validada pode gerar ficha educativa; criação idempotente por versão/lacuna/modelo de intervenção; custos e metas permanecem estimativas até decisão | +| RB13 | Idempotency-Key inclui escopo do actor/operação; mesma chave/conteúdo retorna protocolo; conteúdo diferente 409; reserva e evento atómicos | +| RB14 | If-Match corresponde à versão autorizada; ausente 428, desactualizado 412; não substituir versão mais recente | +| RB15 | Interesse não garante emprego, financiamento, autorização institucional ou retorno; encaminhamento humano com responsável e próxima acção | +| RB16 | Revogação ou alteração de mandato afecta imediatamente novos acessos, downloads, exportações e sincronizações; eventos já entregues seguem procedimento de remediação | +| RB17 | Fonte, método, decisão e regra de alocação acompanham cada resultado; correcção gera nova versão, não reescreve história | + +Exemplo didáctico em `examples/` usa apenas inteiros sintéticos. Não estima mercado real, acreditação, probabilidades ou taxas. Dados sem evidência recebem «Pendente de levantamento e validação pela entidade competente». + +## 13. Perfis e permissões + +| Perfil | Pode propor/submeter | Pode validar/publicar | Limite | +| -------------------- | ------------------------------------------------------ | ------------------------------------------------ | ---------------------------------------------- | +| Administrador | Configuração técnica aprovada | Não declara acreditação | Sem acesso pessoal por defeito | +| Gestor Education | Tramitar, devolver, suspender divulgação | Publicação editorial após parecer distinto | Não valida a própria submissão | +| Coordenador cluster | Procura/contexto do cluster mandatado | Parecer territorial mandatado | Não extrapola para outro cluster | +| Coordenador corredor | Contexto e necessidades do corredor | Parecer no seu mandato | Não aprova certificações | +| Promotor | Procura/projecto e resposta educativa própria | Não sozinho | Sem acesso a candidatos | +| Empregador | Procura, capacidade de estágio, resultados autorizados | Confirma absorção com evidência | Não certifica cursos | +| Instituição | Oferta, capacidade, programas e resultados próprios | Confirma reportes próprios; revisão por terceiro | Não atribui acreditação ao seu cadastro | +| Tutela | Parecer/decisão no mandato | Decisão institucional no escopo formal | Não é acesso global implícito | +| Acreditadora | Decisões e evidências sob competência comprovada | Confirma acreditação externa própria | Verificação e importação segregadas | +| Financiador | Interesse/compromisso/bolsa | Aprova financiamento no seu processo | Não certifica; sem dados individuais de alunos | +| Investidor | Favoritos/interesse em fichas públicas | Não | Apenas resultados agregados autorizados | +| Fornecedor | Interesse/proposta técnica | Não | Sem acesso a dossiers privados sem autorização | +| Formador | Perfil/ disponibilidade próprios | Não | Sem inferência automática de competências | +| Formando | Dados e consentimentos próprios | Contesta/corrige por processo | Não vê outros formandos | +| Analista | Cálculo/correspondência/recomendação | Não aprova a própria recomendação | Agregados ou pseudónimos autorizados | +| Auditor | Não altera dados de negócio | Consulta trilha no escopo | Acesso auditado, minimizado e temporário | +| Público | Não escreve | Não | Só projecções publicadas e agregadas | + +Negar por defeito. A decisão combina identidade, papel, organização representada, mandato, finalidade, classificação, território, validade e estado. Nenhum UUID, URL ou chave de idempotência concede acesso. Estas permissões são alvo, não capabilities já instaladas. + +## 14. RACI + +R executa; A responde pela decisão final; C consultado; I informado. Cada linha tem um A. Organizações/pessoas concretas permanecem por nomear. + +| Acção | R | A | C | I | +| ------------------------------- | ----------------------------- | --------------------------------- | ------------------------------- | ---------------------- | +| Aprovar âmbito/piloto/orçamento | Produto | Patrocinador | Comité Executivo, coordenadores | Equipa | +| Propor/submeter procura/oferta | Empregador/instituição | Responsável dos dados da entidade | Coordenadores | Gestor | +| Validar fonte/taxonomia | Secretariado | Entidade competente | Dados, coordenadores | Produto | +| Aprovar arquitectura/contratos | Equipa técnica | Comité de Arquitectura | Segurança, CRM | Executivo | +| Validar lacuna | Analista independente | Validador institucional mandatado | Empregador, instituição | Gestor | +| Aprovar/publicar ficha | Revisor editorial | Dono do Produto | Validador e promotor | Público autorizado | +| Confirmar decisão externa | Verificador mandatado | Entidade competente emissora | Jurídico, Secretariado | Gestor | +| Corrigir/suspender dados | Gestor + responsável de dados | Secretariado de Dados | Tutela, Segurança | Consumidores afectados | +| Aprovar modelo/regras IA | Equipa IA | Comité de IA e Dados | Arquitectura, entidades | Produto | +| Auditar acessos/resultados | Auditor independente | Comité Executivo | Segurança, Secretariado | Patrocinador | +| Autorizar produção/expansão | Produto + Operações | Comité Executivo | Todos os comités e responsáveis | Utilizadores afectados | + +## 15. Integrações, propriedade e sincronização + +| Dados | Mestre → consumidor | Campos partilhados | Cadência/conflicto/falha | Reconciliação | +| --------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------- | +| Procura, lacuna, capacidade, curso, mapa | Portal Education/Shared → CRM só contexto | UUID, título autorizado, estado editorial, URL | Eventos após commit; versão maior; sem sobrescrever decisão institucional | Secretariado | +| Contacto/organização comercial | CRM local na transição; SuiteCRM após corte → portal | ID externo, nome/contacto mínimo, estado comercial | Um escritor por fase; versão/owner; conflito vai a revisão, nunca last-write-wins indiscriminado | Responsável CRM | +| Interesse educativo | Portal → CRM | UUID, finalidade, papel, contexto, contacto consentido | Outbox; recepção idempotente; erros ambíguos exigem lookup/reconciliação antes de repetir POST | Operações CRM | +| Lead/tarefa/reunião/proposta/compromisso | CRM → painel autorizado | Estado, responsável, prazo, próxima acção | Após evento ou consulta incremental proposta; deduplicar, revalidar consentimento | Gestor CRM | +| Acreditação/certificação/validação curricular | Entidade competente → portal | Referência, escopo, emissor, validade, evidência autorizada | F1 registo documental verificado; F3 API apenas com acordo; nenhuma escrita de volta que altere decisão | Entidade + Secretariado | +| Beneficiários e resultados pessoais | Instituição/empregador autorizado → Education | Só mínimo necessário, consentimento e finalidade | F2; nunca sincronizar notas, saúde ou localização individual para CRM | Responsável de dados | + +O sincronizador real está pendente. Backoff, dead-letter e replay controlado são propostas; capturar outbox não equivale a entregar. Tombstones e correcções são mensagens auditadas; não apagar remotamente por uma falha local. Acordos documentam payload, fundamento, destinatário, retenção e revogação. + +## 16. Eventos de domínio + +Envelope proposto: event_id UUID, event_type, schema_version, aggregate_id UUID, aggregate_version, occurred_at UTC, correlation_id, source_system, classification, payload mínimo referencial. Sem documentos, nomes de formandos ou tokens. Outbox/inbox existentes a reutilizar após confirmar índices e ledger de consumidores; unique(consumer,event_id), processamento transaccional, rejeição de versão incompatível. + +| Fase | Eventos | Produtor → consumidor | +| ----- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| F1 | LabourDemandSubmitted, LabourDemandValidated | Education → revisão/cálculo | +| F1 | TrainingCapacityUpdated, SkillGapDetected, SkillGapValidated | Education → analista/painel | +| F1 | EducationInvestmentOpportunityCreated, TrainingProviderMatched | Education → revisão/editorial; matching não aprova | +| F1 | FunderInterestRegistered, InvestorInterestRegistered | Portal → adaptador CRM; mapear ao evento de interesse existente sem dupla emissão comercial | +| F1 | DataValidationExpired | Validade → dependências/painel, sem alterar decisão oficial externa | +| F2 | EmployerPartnershipCreated, CurriculumReviewRequested | Education → tramitação e partes autorizadas | +| F2/F3 | AccreditationStatusReceived, CertificationReported | Adaptador externo verificado → registo de evidência | +| F2 | TrainingCohortStarted, TrainingCompleted, InternshipStarted | Instituição → acompanhamento autorizado | +| F2 | EmploymentOutcomeReported, SkillGapReduced, SkillGapReopened | Empregador/analista → monitoria e revisão | + +Não há tópicos, filas novas ou listeners activados. Propostas de artefactos em §23; contract tests devem detectar duplicação, reorder, revogação e mensagens venenosas. + +## 17. Endpoints e contratos + +[openapi.proposed.json](openapi.proposed.json) é OpenAPI 3.1 **Proposta**, não rotas instaladas. Prefixo novo `/api/v1/education`; não modificar `routes/api.php` nesta entrega. O documento define o núcleo LabourDemand; os endpoints restantes constam da matriz RF com contratos detalhados pendentes, não são apresentados como completos. + +| Família proposta | Operações | Requisitos | +| ------------------------------------------- | ---------------------------------------------------- | ------------- | +| skills / occupations | GET, POST, PATCH com versão | 001, 017 | +| labour-demands | GET cursor, POST idempotente, GET/PATCH por UUID | 002, 009, 018 | +| labour-demands/{id}/decisions | POST decisão segregada e If-Match | 009, 032 | +| providers / courses / capacities | Cadastro, declaração de capacidade e evidências | 003, 007, 019 | +| gaps / matches | Criar análise, consultar explicação, decisão humana | 004, 011, 020 | +| investment-opportunities | Rascunho, revisão e publicação | 005, 006 | +| investment-opportunities/{id}/interests | POST consentido e idempotente | 008, 021 | +| responses / equipment-needs / funding-needs | Resposta e inventário indicativo MVP | 010, 022–024 | +| indicators / map | GET agregados filtrados, sem pontos pessoais | 015, 031 | +| ai-recommendations / audit | GET autorizado, decisão humana; auditoria só leitura | 014, 032 | + +Autenticação alvo: OAuth2 para autorização, OIDC para identidade, issuer/JWKS/audience confiáveis, scopes e mandato contextual, expiração e revogação. Não reutilizar credenciais OAuth do SuiteCRM para autenticar utilizadores do portal. Issuer, fluxos e scopes ainda não configurados (B03); contrato usa esquema bearer indicativo e extensões que identificam esta pendência, sem inventar URLs de token/discovery. + +JSON; UUID de recursos Education; ISO 8601; ISO 4217; ISO 3166-1 alpha-2 para país; cursor opaco ligado aos filtros/escopo, ordem estável `(updated_at,id)`; máximo proposto 100 itens; X-Correlation-Id validado ou gerado no servidor. Erros `application/problem+json` com type/title/status/detail/instance/correlation_id e erros de campo sanitizados. 401 token, 403 escopo, 404 recurso inacessível, 409 conflito/idempotência, 412 versão, 422 regra, 428 precondição, 429 limite, 503 dependência. ETag opaco forte para a versão; If-Match obrigatório em alterações e decisões. Rotação/retensão do ledger de idempotência deve ser aprovada antes de activação. Mudança incompatível requer nova versão principal e transição. + +## 18. Modelo CRM + +Accounts para organizações, Contacts para interlocutores autorizados, Leads para interesse qualificado, Opportunities para negociação, Tasks/Meetings para acções, Campaigns para promoção; nomes de campos, ligações e módulos customizados dependem de descoberta da instalação. Não criar módulos SuiteCRM por suposição. Chave canónica da oportunidade educativa e origem projecto/cluster/corredor/território acompanham contexto. Separar montante indicado, proposta, compromisso e investimento realizado. Nenhum destes implica certificação ou emprego. + +Papéis investidor/financiador/instituição/empregador/fornecedor pertencem a relações, não cinco contactos duplicados. Responsável, próxima acção e SLA aprovado obrigatórios antes de avançar a fase comercial. Quando B04 persistir, gerir localmente sob o modelo já existente, sem declarar sincronização nem activar escrita dupla. + +## 19. Modelo GIS + +Reutilizar geo_locations e geo_geometries; atribuir locais institucionais e áreas de cobertura autorizadas. ST_IsValid, SRID 4326 e envelope mundial já têm constraints. Distância métrica deve usar geography ou projecção apropriada, não graus. Distância em linha recta não é duração de viagem; rede rodoviária, transporte, acessibilidade e metodologia são fontes ainda não seleccionadas. Mapas mostram agregados por território, procura e oferta com unidade/período, validade e limitações; esconder ou agregar células abaixo do limiar de divulgação aprovado. Sem fonte/limiar aprovado: manter camada interna/desactivada. Não publicar residências ou trajectos de pessoas. + +## 20. Arquitectura de IA + +Inicialmente o motor de cálculo é determinístico e explicável. IA é capacidade separada, condicionada a B07: ingestão de fontes autorizadas → validação/limpeza → recuperação contextual → geração de proposta → filtros de dados pessoais e autoridade → revisão humana → publicação autorizada → monitoria. Não seleccionar fornecedor, instalar modelo ou enviar dados neste pacote. + +Cada saída guarda referências/versionamento das entradas, fonte, data, modelo/versão, prompt/método versionado sem segredos, explicação, confiança calibrada ou indisponível, limitações, estado de recomendação, revisor, decisão e data. Texto de fontes é dado não confiável: instruções contidas em currículos/documentos não podem executar ferramentas nem modificar permissões. + +Avaliação antes de activar: conjunto de referência aprovado, exactidão de citações, taxa de recomendações úteis, omissões perigosas, disparidades de erro por território/sector, contaminação de dados, fuga de informação, robustez a instruções maliciosas e revisão humana. Thresholds de qualidade/modelo dependem do Comité; zero atribuições de estados oficiais é condição absoluta. + +## 21. Matriz de limites da IA + +| Permitido como proposta | Controlo | Proibido | +| ---------------------------------------------------- | --------------------------------------------- | ---------------------------------------------- | +| Extrair competências/ocupações de fontes autorizadas | Citações, validação taxonómica e revisão | Inferir competências individuais sem evidência | +| Sugerir correspondências/currículo a rever | Explicação e condições eliminatórias visíveis | Acreditar, certificar ou aprovar currículo | +| Estimar procura/risco e cenários | Horizonte, pressupostos e intervalo | Previsão vinculativa ou emprego garantido | +| Resumir dados de conclusão/inserção | Agregação, autorização, reconciliação | Decidir admissão, exclusão ou contratação | +| Assinalar divergência/expiração | Tarefa ao responsável e confirmação humana | Transformar estimativa em decisão oficial | +| Sugerir localização de programas | Fonte GIS, incerteza e avaliação humana | Publicar recomendação sem revisão | + +## 22. Indicadores + +[indicators.csv](indicators.csv) contém os 25 indicadores solicitados: definição/fórmula, unidade, fonte, frequência, responsável, linha de base, meta, validação e divulgação. Todos se segmentam por projecto, cluster, corredor e território quando a relação é comprovada, além de período e ocupação/competência pertinentes. + +Denominador zero ou população incompleta produz «não disponível», não 0%. Pessoas em vários cursos são contadas uma vez em indicadores de pessoas; episódios são contados separadamente. Metas, baselines, custos e efeitos causais não são inventados. Inserção confirmada não prova que o programa causou o emprego. Reconciliar contagens com fontes antes de divulgar. + +## 23. Backlog e artefactos para programação + +| Pacote | RF | Localizações propostas, não criadas nesta entrega | Dependência / teste | +| ----------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| EDU-A Taxonomias e procura | 001/002/009/017/018 | `app/Domain/Education/{Models,Requests,Policies,Services}`; `app/Http/Controllers/Api/V1/Education`; migration aditiva após B02/B03 | T-001/T-002; schemas e autorizações | +| EDU-B Oferta e capacidade | 003/007/019 | mesmos domínios, adaptadores dos modelos existentes; FormRequests; decisão/evidência separadas | T-003/T-007; não interpretar approved como acreditado | +| EDU-C Lacunas/correspondência | 004/011/020 | `Services/GapCalculator`, `MatchExplainer`; Jobs/Listeners apenas após contratos | T-004/T-011; cálculo puro e alocação concorrente | +| EDU-D Oportunidade/interesse | 005/006/008/021 | adaptador para Opportunity/InterestSubmission; Policy contextual; eventos após commit | T-005/T-008; idempotência e CRM | +| EDU-E Pesquisa/mapa/painel | 015/031 | `resources/js/pages/education`, `components/education`, `hooks/useEducation`, serviço API e ficheiros PT/EN propostos | T-015/T-031; acessibilidade e dados agregados | +| EDU-F Revisão/IA/auditoria | 014/032/033 | Services de decisão; Policies; Jobs de expiração; listeners de invalidação; interface de revisão | T-014/T-032; RBAC/anti-autovalidação | +| EDU-G Qualidade e migração | 016/033 | `tests/Unit/Education`, `tests/Feature/Education`, contratos e E2E; scripts separados sem SQL de remoção | ensaio/restauro e gates G0–G3 | + +Interfaces propostas: I01 catálogo; I02 formulário de procura; I03 oferta/capacidade; I04 lacuna e explicação; I05 ficha educativa/interesse; I06 fila de revisão; I07 mapa/painel; I08 acompanhamento F2. Mensagens e estados PT/EN devem partilhar chaves; não traduzir códigos de dados. Componentes existentes de formulário, conta, navegação e mensagens são reutilizados. Repositório/convenções podem motivar adaptar os caminhos antes da aprovação, sem criar uma segunda aplicação. + +## 24. Plano MVP e selecção + +| Critério | Evidência necessária | Regra proposta | +| --------------------------- | -------------------------------------------------- | -------------------------------------------------------- | +| Dados | Fonte, actualidade, completude, validade, direitos | Critério eliminatório se faltam campos críticos | +| Relevância/emprego | Evidência económica e procura documentada | Pontuação ordinal 0–3 proposta; sem dados = não avaliado | +| Projecto-piloto | Confirmação formal e responsável | Eliminatório para entrada | +| Equilíbrio territorial | Cobertura e diversidade justificadas | Comité documenta trade-off; não inventar quota | +| Criticidade de competências | Impacto e prazo comprovados | Não confundir volume declarado com validado | +| Capacidade institucional | Mandatos, equipa, calendário, compromisso | Eliminatório se não há validador | +| Oferta e parceiros | Instituições/empregadores participantes | Evidência de disponibilidade, não lista presumida | +| Integração | Contratos, dados e ambiente viáveis | Sem externalidade não formalizada | +| Custo/prazo/risco | Estimativa validada e mitigação | Orçamento pendente; não calcular retorno fictício | +| Replicabilidade | Lições e condições de expansão | Critério comparativo após elegibilidade | + +Pesos e limiar agregados pendentes de aprovação; não reaproveitar pontuações comerciais dos clusters como pontuação de prontidão educativa. Após validar elegibilidade, comparar candidatos com critérios/versão uniformes, justificar selecção de até três de cada tipo e guardar decisão. Arquitectura não codifica limite permanente de três. + +Entrega F1 em pequenos lotes: A/B → C → D → E/F → ensaios G. Dependências B01–B08 impedem prometer datas absolutas ou activar funcionalidades sem evidência. Feature flag e allowlist de pilotos são propostas de mecanismo; nomes/configuração ainda não criados. + +## 25. Roteiro por fases e expansão + +| Fase/vaga | Responsável | Entrada / saída | Dependências | Calendário e orçamento | Risco / mitigação | +| -------------------------- | ------------------------------ | ----------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------- | +| F0 Diagnóstico/governação | Produto + Secretariado | Mandatos/fontes → G0 | B01/B05/B06 | Pendente de plano aprovado; esforço de levantamento a estimar | Fonte sem autorização → quarentena | +| F1 Piloto até 3+3 | Equipa técnica + coordenadores | G0 → G1/G2 | B02/B03/B04/B07/B08 | Estimar por pacotes A–G após inventário | Complexidade → escopo pequeno e flag desligada | +| F2 Programas/financiamento | Instituições + Produto | Piloto aceite → G3 por programa | Consentimento, parceiros, recursos | Orçamento por programas sem montantes presumidos | Exposição pessoal → minimização e controle granular | +| F3 Interoperabilidade | Arquitectura + entidades | Acordos e contratos → homologação externa | Sistemas/fontes oficiais | Sequência por disponibilidade institucional | Divergência → reconciliação/manual | +| F4 Inteligência avançada | Comité IA e Dados | Métricas validadas → revisão periódica | Dados representativos e observação longitudinal | Calendarização após evidência de F1/F2 | Viés/deriva → suspensão e revisão | +| Expansão vaga n | Coordenadores + Operações | G2/G3 do piloto → Go da vaga | Qualidade/capacidade/suporte de novos territórios | Estimativa específica, aprovada antes da vaga | Regressão → canário, monitoria e reversão por flag | + +Processo repetível: inventariar candidatos → pontuar e deliberar → preparar pequeno grupo → reconciliar e testar → activar gradualmente → medir e corrigir. Não colocar todos os corredores da carteira em produção educativa só porque já têm cadastro. Cada vaga tem responsável nominal, orçamento, calendário, entrada/saída, riscos e decisão registados antes de activação. + +## 26. Riscos + +| ID | Risco | Controlo / responsável | +| --- | ------------------------------------------------ | ------------------------------------------------------- | +| R01 | Estado editorial confundido com acreditação | RB02, separação de registos; entidade competente | +| R02 | Vagas/competências duplicadas como trabalhadores | RB03–RB08, pools e testes; analista | +| R03 | Exposição de alunos/localização | Policies, minimização e agregação; responsável de dados | +| R04 | Identidades ou campos comerciais divergentes | Um mestre, UUID e reconciliação; CRM | +| R05 | Fonte vencida/modelo sem base | Validade, versionamento e suspensão; Secretariado/IA | +| R06 | Quebra de contratos/tabelas existentes | Extensão aditiva e testes de regressão; Arquitectura | +| R07 | Sem recursos/mandatos para operar | Piloto e Go/No-Go; Patrocinador | +| R08 | Importação errada ou perda de histórico | Quarentena, reconciliação, restauro; Operações | + +## 27. Testes e evidência + +Cada T-n corresponde ao RF de mesmo número na matriz. Casos obrigatórios: autor tenta aprovar; gestor tenta atribuir acreditação; token sem scope/mandato expirado; aluno de outro tenant; documento em quarentena; fonte expirada; procura duplicada por competência; capacidade partilhada entre cursos; período/nível/unidade incompatíveis; dados em falta; negativas/overflow; conflito If-Match; mesma chave com payload diferente; falha após commit; CRM indisponível; eventos duplicados/fora de ordem; revogação antes de replay; instrução maliciosa na fonte IA; exportação com células pequenas; rollback e restauro. + +Exemplos e testes em `examples/gap-calculator.*.mjs` exercitam apenas o contrato matemático proposto com dados sintéticos. Testes de consistência do pacote verificam IDs, referências e OpenAPI; não substituem validação semântica OpenAPI completa nem testes funcionais do domínio. A suite anterior do portal não demonstra RF-EDU cumpridos. + +Homologação exige testes unitários, integração PostgreSQL/PostGIS isolada, contrato, E2E PT/EN, concorrência, autorização, privacidade, acessibilidade manual, carga, falhas, recuperação e regressão do portal. Sem ambiente/fontes aprovados: registar «não executado», nunca «aprovado». + +## 28. Critérios Go/No-Go + +| Gate | Critérios cumulativos | Aprovador | Estado actual | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ------------- | +| G0 Desenvolvimento integrado | Escopo, pilotos, modelo canónico B02, responsáveis, fontes, taxonomias e arquitectura aprovados; branch própria | Produto + Arquitectura | Pendente | +| G1 Homologação | Código revisto, análise estática, testes de regras e isolamento, contratos, flags e migração/restauro em cópia aprovados | Arquitectura + Segurança | Pendente | +| G2 Produção piloto | B01–B08 resolvidos no âmbito activado; permissões, limites oficiais, indicadores, privacidade, acessibilidade, desempenho, suporte e rollback aceites; zero defeitos críticos | Comité Executivo | NO-GO actual | +| G3 Expansão | Piloto medido, novos dados/mandatos/recursos validados, regressão/reconciliação e capacidade operacional aprovadas | Comité Executivo | Pendente | + +O MVP inclui IA assistiva e integração CRM como alvo. Enquanto os respectivos bloqueios persistirem, não declarar o MVP integral concluído; redução formal de escopo deve ser uma decisão explícita, não uma exclusão silenciosa. + +## 29. Governação, migração e operação + +Secretariado mantém fontes, dicionários, decisões e prazos; gestores fazem triagem; coordenadores confirmam contexto; comités deliberam alterações materiais. Proposta de cadência: triagem semanal, qualidade mensal, estratégia trimestral e revisão imediata em incidente, sujeita a capacidade aprovada. + +[migration-proposal.md](migration-proposal.md) define inventário, extensão, mapeamento, quarentena, ensaio, reconciliação, backup/restauro e reversão. Não contém comandos de remoção nem migrations activas. Como as seis tabelas estão vazias, não há dados de formação a migrar agora; isso não autoriza eliminar a estrutura ou dispensar levantamento de fontes externas. + +Logs minimizam dados pessoais; métricas de fila/erros/idade da fonte; alertas têm responsável e procedimento; modo degradado conserva protocolo e identifica dados não sincronizados. Incidentes podem suspender camada, fonte, modelo ou conector sem eliminar registos. Mudanças de versão têm análise de impacto, contrato e aprovação técnica. + +## 30. Checklist institucional e conformidade + +- [ ] Entidades e responsáveis nomeados; mandatos, jurisdição/escopo e segregação comprovados. +- [ ] Pilotos escolhidos com critérios comuns; projectos formalmente validados. +- [ ] Taxonomias, níveis, fontes, periodicidade, validade, licença e regras de divulgação aprovados. +- [ ] Situações declarada, em análise, validada, acreditada, certificada, expirada, suspensa e rejeitada distintas; recomendação IA nunca decisão. +- [ ] Tratamento, consentimento, representação, menores, retenção e partilha validados. +- [ ] Contratos, ownership, idempotência, versão, falhas, reconciliação e rollback aprovados. +- [ ] Métricas, orçamento, apoio operacional, testes e Go/No-Go assinados. +- [ ] Nenhum dado, custo, emprego, curso ou acreditação foi inventado para preencher o MVP. + +[conformity.csv](conformity.csv) relaciona princípios, 20 blocos, tecnologias, integrações, segurança, governação, MVP e exclusões com esta especificação. «Conforme» significa alinhamento documental, nunca certificação de implementação. «Parcialmente conforme» identifica cobertura sem operação; «Pendente de validação» exige decisão/evidência; «Não conforme» identifica lacuna actual face ao alvo. A auditoria geral v4 mantém-se; este pacote não marca RF-EDU como implementados. diff --git a/docs/investmoz/erd.mmd b/docs/investmoz/erd.mmd new file mode 100644 index 00000000..662885a6 --- /dev/null +++ b/docs/investmoz/erd.mmd @@ -0,0 +1,872 @@ +erDiagram + users { + bigint id PK + character_varying name + character_varying email UK + timestamp email_verified_at + character_varying password + character_varying remember_token + timestamp created_at + timestamp updated_at + text two_factor_secret + text two_factor_recovery_codes + timestamp two_factor_confirmed_at + } + password_reset_tokens { + character_varying email PK + character_varying token + timestamp created_at + } + sessions { + character_varying id PK + bigint user_id + character_varying ip_address + text user_agent + text payload + integer last_activity + } + cache { + character_varying key PK + text value + bigint expiration + } + cache_locks { + character_varying key PK + character_varying owner + bigint expiration + } + jobs { + bigint id PK + character_varying queue + text payload + smallint attempts + integer reserved_at + integer available_at + integer created_at + } + job_batches { + character_varying id PK + character_varying name + integer total_jobs + integer pending_jobs + integer failed_jobs + text failed_job_ids + text options + integer cancelled_at + integer created_at + integer finished_at + } + failed_jobs { + bigint id PK + character_varying uuid UK + character_varying connection + character_varying queue + text payload + text exception + timestamp failed_at + } + passkeys { + bigint id PK + bigint user_id FK + character_varying name + character_varying credential_id UK + json credential + timestamp last_used_at + timestamp created_at + timestamp updated_at + } + sectors { + bigint id PK + character_varying name + character_varying slug UK + character_varying short_description + text description + character_varying icon + integer sort_order + boolean is_active + timestamp published_at + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + opportunities { + bigint id PK + bigint sector_id FK + character_varying reference UK + character_varying title + character_varying slug UK + character_varying summary + text description + character_varying province + character_varying district + character_varying promoter_name + character_varying promoter_type + smallint maturity_level + character_varying maturity_label + numeric estimated_investment_amount + character_varying estimated_investment_currency + character_varying financing_modality + text expected_impact + character_varying status + boolean is_featured + timestamp published_at + timestamp last_validated_at + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + interest_requests { + bigint id PK + bigint opportunity_id FK + character_varying reference UK + character_varying full_name + character_varying email + character_varying phone + character_varying investor_type + character_varying country + character_varying sector_interest + character_varying opportunity_reference + character_varying investment_range + text message + boolean privacy_consent + timestamp consented_at + character_varying status + character_varying source_page + inet ip_address + text user_agent + json metadata + timestamp reviewed_at + bigint reviewed_by FK + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + organizations { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text name + text organization_type + } + memberships { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + bigint user_id FK + uuid organization_id FK + text role + } + consents { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + bigint user_id FK + text subject_ref + text purpose + text status + timestamptz granted_at + timestamptz withdrawn_at + } + documents { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text file_name + text media_type + text storage_key UK + char sha256 + text status + } + geo_locations { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text name + text location_type + uuid parent_id FK + text code + } + geo_geometries { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid geo_location_id FK + text geometry_type + geometry geom + jsonb properties + } + projects { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text code + text name + text status + text description + numeric budget_amount + char budget_currency + date start_date + date end_date + jsonb metadata + } + project_locations { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid project_id FK + uuid geo_location_id FK + text role + } + economic_clusters { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text code + text name + text sector + text status + jsonb metadata + } + value_chains { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid cluster_id FK + text code + text name + text stage + } + value_chain_links { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid from_value_chain_id FK + uuid to_value_chain_id FK + text link_type + } + cluster_projects { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid cluster_id FK + uuid project_id FK + text role + } + anchor_project_links { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid anchor_project_id FK + uuid cluster_id FK + text rationale + } + complementary_project_links { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid anchor_project_id FK + uuid complementary_project_id FK + text relation_type + } + development_corridors { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text code + text name + text status + text description + } + corridor_locations { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid corridor_id FK + uuid geo_location_id FK + integer sequence_no + } + corridor_projects { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid corridor_id FK + uuid project_id FK + } + corridor_clusters { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid corridor_id FK + uuid cluster_id FK + } + investors { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid organization_id FK + text legal_name + text investor_type + text status + } + contacts { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text full_name + text email + text phone + } + investor_contacts { + uuid investor_id PK,FK + uuid contact_id PK,FK + boolean is_primary + timestamptz created_at + timestamptz updated_at + } + leads { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid investor_id FK + bigint owner_user_id FK + text title + text status + numeric estimated_amount + char currency + } + crm_opportunities { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid lead_id FK + uuid project_id FK + bigint owner_user_id FK + text stage + numeric amount + char currency + numeric probability + } + activities { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid investor_id FK + uuid opportunity_id FK + bigint actor_user_id FK + text activity_type + timestamptz occurred_at + text summary + } + tasks { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + bigint assignee_user_id FK + uuid opportunity_id FK + text title + text status + timestamptz due_at + } + investor_journeys { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid investor_id FK + uuid opportunity_id FK + text status + timestamptz started_at + } + journey_steps { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid journey_id FK + text step_code + integer sequence_no + text status + } + submissions { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid journey_step_id FK + bigint submitted_by FK + text status + jsonb payload + timestamptz submitted_at + } + aftercare_plans { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid investor_id FK + uuid project_id FK + bigint owner_user_id FK + text status + date review_date + } + constraints { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid aftercare_plan_id FK + text category + text description + text severity + text status + } + escalations { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid constraint_id FK + bigint escalated_by FK + text level + text status + timestamptz escalated_at + } + skills { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text code + text name + } + occupations { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text code + text name + } + training_needs { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid project_id FK + uuid occupation_id FK + uuid skill_id FK + integer quantity + text priority + } + training_providers { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid organization_id FK + text name + text status + } + courses { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid provider_id FK + text code + text title + text status + } + education_opportunities { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid course_id FK + uuid geo_location_id FK + date start_date + date end_date + integer capacity + text status + } + project_documents { + uuid project_id PK,FK + uuid document_id PK,FK + timestamptz created_at + } + investor_documents { + uuid investor_id PK,FK + uuid document_id PK,FK + timestamptz created_at + } + journey_documents { + uuid journey_id PK,FK + uuid document_id PK,FK + timestamptz created_at + } + aftercare_documents { + uuid aftercare_plan_id PK,FK + uuid document_id PK,FK + timestamptz created_at + } + education_documents { + uuid education_opportunity_id PK,FK + uuid document_id PK,FK + timestamptz created_at + } + audit_events { + uuid id PK + text source_system + text external_id + text data_classification + uuid correlation_id + timestamptz created_at + bigint actor_user_id FK + text entity_type + uuid entity_id + text action + timestamptz occurred_at + jsonb before_data + jsonb after_data + } + outbox_events { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + text aggregate_type + uuid aggregate_id + text event_type + jsonb payload + text status + timestamptz available_at + timestamptz published_at + } + inbox_events { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + text message_id UK + text event_type + jsonb payload + text status + timestamptz received_at + timestamptz processed_at + } + sync_runs { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + text integration_name + text direction + text status + timestamptz started_at + timestamptz ended_at + jsonb totals + } + reconciliation_issues { + uuid id PK + text source_system + text external_id + integer version_no + text data_classification + uuid correlation_id + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at + uuid sync_run_id FK + text entity_type + text external_id_ref + text issue_type + text severity + text status + jsonb details + } + users || --o{ passkeys : user_id + sectors || --o{ opportunities : sector_id + opportunities |o --o{ interest_requests : opportunity_id + users |o --o{ interest_requests : reviewed_by + users || --o{ memberships : user_id + organizations || --o{ memberships : organization_id + users |o --o{ consents : user_id + geo_locations |o --o{ geo_locations : parent_id + geo_locations || --o{ geo_geometries : geo_location_id + projects || --o{ project_locations : project_id + geo_locations || --o{ project_locations : geo_location_id + economic_clusters || --o{ value_chains : cluster_id + value_chains || --o{ value_chain_links : from_value_chain_id + value_chains || --o{ value_chain_links : to_value_chain_id + economic_clusters || --o{ cluster_projects : cluster_id + projects || --o{ cluster_projects : project_id + projects || --o{ anchor_project_links : anchor_project_id + economic_clusters || --o{ anchor_project_links : cluster_id + projects || --o{ complementary_project_links : anchor_project_id + projects || --o{ complementary_project_links : complementary_project_id + development_corridors || --o{ corridor_locations : corridor_id + geo_locations || --o{ corridor_locations : geo_location_id + development_corridors || --o{ corridor_projects : corridor_id + projects || --o{ corridor_projects : project_id + development_corridors || --o{ corridor_clusters : corridor_id + economic_clusters || --o{ corridor_clusters : cluster_id + organizations |o --o{ investors : organization_id + investors || --o{ investor_contacts : investor_id + contacts || --o{ investor_contacts : contact_id + investors |o --o{ leads : investor_id + users |o --o{ leads : owner_user_id + leads |o --o{ crm_opportunities : lead_id + projects |o --o{ crm_opportunities : project_id + users |o --o{ crm_opportunities : owner_user_id + investors |o --o{ activities : investor_id + crm_opportunities |o --o{ activities : opportunity_id + users |o --o{ activities : actor_user_id + users |o --o{ tasks : assignee_user_id + crm_opportunities |o --o{ tasks : opportunity_id + investors || --o{ investor_journeys : investor_id + crm_opportunities |o --o{ investor_journeys : opportunity_id + investor_journeys || --o{ journey_steps : journey_id + journey_steps || --o{ submissions : journey_step_id + users |o --o{ submissions : submitted_by + investors || --o{ aftercare_plans : investor_id + projects |o --o{ aftercare_plans : project_id + users |o --o{ aftercare_plans : owner_user_id + aftercare_plans || --o{ constraints : aftercare_plan_id + constraints || --o{ escalations : constraint_id + users |o --o{ escalations : escalated_by + projects |o --o{ training_needs : project_id + occupations |o --o{ training_needs : occupation_id + skills |o --o{ training_needs : skill_id + organizations |o --o{ training_providers : organization_id + training_providers || --o{ courses : provider_id + courses || --o{ education_opportunities : course_id + geo_locations |o --o{ education_opportunities : geo_location_id + projects || --o{ project_documents : project_id + documents || --o{ project_documents : document_id + investors || --o{ investor_documents : investor_id + documents || --o{ investor_documents : document_id + investor_journeys || --o{ journey_documents : journey_id + documents || --o{ journey_documents : document_id + aftercare_plans || --o{ aftercare_documents : aftercare_plan_id + documents || --o{ aftercare_documents : document_id + education_opportunities || --o{ education_documents : education_opportunity_id + documents || --o{ education_documents : document_id + users |o --o{ audit_events : actor_user_id + sync_runs || --o{ reconciliation_issues : sync_run_id diff --git a/docs/investmoz/frontend-integration.md b/docs/investmoz/frontend-integration.md new file mode 100644 index 00000000..bb265a47 --- /dev/null +++ b/docs/investmoz/frontend-integration.md @@ -0,0 +1,49 @@ +# Integração do portal com a API + +## Ligações públicas + +O catálogo abre em `/oportunidades`, as fichas em `/oportunidades/{slug}` e os +sectores em `/sectores/{slug}`. O formulário geral abre em `/manifestar-interesse`; +acrescentar `/{slug}` selecciona uma oportunidade publicada. As rotas podem ser +abertas directamente ou recarregadas. A navegação usa o histórico do Inertia. +O servidor devolve 404 para oportunidades indisponíveis e sectores inactivos. +O teste `PublicPortalNavigationTest` cobre estes contratos (52 verificações). + +Verificação no Edge concluída em 25/09/2026: abertura pela ficha, recarregamento, +formulário contextual, Voltar/Avançar, ligação de sector e regresso ao início. +As verificações no browser não gravaram pedidos. + +Foi corrigida uma falha no cache persistente: colecções PHP podiam regressar como +objectos incompletos, causando uma página em branco. O cache `portal_home_v2_*` +guarda apenas arrays e valores simples, e é invalidado nas alterações do catálogo. +O teste de regressão verifica a serialização com classes desactivadas, a segunda +leitura e a invalidação. Os testes de navegação/cache e gestão passaram em conjunto +(9 testes, 180 verificações). + +A página pública `PortalInvestidor` consulta `/api/v1/home` para os sectores, +estatísticas e destaques. O catálogo usa `/api/v1/opportunities`, com pesquisa, +filtros e paginação no servidor. A ficha consulta a oportunidade pelo slug. + +O formulário envia `POST /api/v1/interest-requests`, incluindo `fullName`, +consentimento e a referência da oportunidade seleccionada. A confirmação só +aparece quando a API devolve sucesso e uma referência. Erros de validação são +apresentados junto dos campos; falhas de rede e limites de pedidos preservam os +dados preenchidos. Envios simultâneos são bloqueados durante o processamento. + +## Verificação + +Executar os 12 testes do cliente HTTP e dos contratos com Node 24: + +```powershell +node --test tests/frontend/portal-api.test.mjs +npm run types:check +npm run build +``` + +Em 24/09/2026, passaram os testes acima e a verificação de lint dos ficheiros +alterados. Foi também verificada a navegação em Edge: início, catálogo, pesquisa +sem distinção de maiúsculas, filtros e ficha consultaram a API local real. +O formulário foi verificado com respostas simuladas 422, 429 e 201, incluindo +retenção de dados, referência devolvida pelo servidor e prevenção de envio +duplicado. Nenhum pedido fictício foi persistido nesses testes de navegador. +O formulário foi inspeccionado num ecrã de 390 px, sem transbordamento horizontal. diff --git a/docs/investmoz/indexes.csv b/docs/investmoz/indexes.csv new file mode 100644 index 00000000..1d12b136 --- /dev/null +++ b/docs/investmoz/indexes.csv @@ -0,0 +1,285 @@ +Tabela,Índice,Colunas,Método,Único,Predicado,Migration +users,users_email_unique,email,btree,True,,database/migrations/0001_01_01_000000_create_users_table.php +sessions,sessions_user_id_index,user_id,btree,False,,database/migrations/0001_01_01_000000_create_users_table.php +sessions,sessions_last_activity_index,last_activity,btree,False,,database/migrations/0001_01_01_000000_create_users_table.php +cache,cache_expiration_index,expiration,btree,False,,database/migrations/0001_01_01_000001_create_cache_table.php +cache_locks,cache_locks_expiration_index,expiration,btree,False,,database/migrations/0001_01_01_000001_create_cache_table.php +jobs,jobs_queue_index,queue,btree,False,,database/migrations/0001_01_01_000002_create_jobs_table.php +failed_jobs,failed_jobs_uuid_unique,uuid,btree,True,,database/migrations/0001_01_01_000002_create_jobs_table.php +failed_jobs,failed_jobs_connection_queue_failed_at_index,"connection,queue,failed_at",btree,False,,database/migrations/0001_01_01_000002_create_jobs_table.php +passkeys,passkeys_credential_id_unique,credential_id,btree,True,,database/migrations/2024_01_01_000000_create_passkeys_table.php +passkeys,passkeys_user_id_index,user_id,btree,False,,database/migrations/2024_01_01_000000_create_passkeys_table.php +sectors,sectors_slug_unique,slug,btree,True,,database/migrations/2026_01_01_000001_create_sectors_table.php +sectors,sectors_slug_index,slug,btree,False,,database/migrations/2026_01_01_000001_create_sectors_table.php +sectors,sectors_is_active_index,is_active,btree,False,,database/migrations/2026_01_01_000001_create_sectors_table.php +opportunities,opportunities_reference_unique,reference,btree,True,,database/migrations/2026_01_01_000002_create_opportunities_table.php +opportunities,opportunities_slug_unique,slug,btree,True,,database/migrations/2026_01_01_000002_create_opportunities_table.php +opportunities,opportunities_sector_id_index,sector_id,btree,False,,database/migrations/2026_01_01_000002_create_opportunities_table.php +opportunities,opportunities_reference_index,reference,btree,False,,database/migrations/2026_01_01_000002_create_opportunities_table.php +opportunities,opportunities_slug_index,slug,btree,False,,database/migrations/2026_01_01_000002_create_opportunities_table.php +opportunities,opportunities_status_index,status,btree,False,,database/migrations/2026_01_01_000002_create_opportunities_table.php +opportunities,opportunities_is_featured_index,is_featured,btree,False,,database/migrations/2026_01_01_000002_create_opportunities_table.php +opportunities,opportunities_province_index,province,btree,False,,database/migrations/2026_01_01_000002_create_opportunities_table.php +opportunities,opportunities_maturity_level_index,maturity_level,btree,False,,database/migrations/2026_01_01_000002_create_opportunities_table.php +interest_requests,interest_requests_reference_unique,reference,btree,True,,database/migrations/2026_01_01_000003_create_interest_requests_table.php +interest_requests,interest_requests_reference_index,reference,btree,False,,database/migrations/2026_01_01_000003_create_interest_requests_table.php +interest_requests,interest_requests_email_index,email,btree,False,,database/migrations/2026_01_01_000003_create_interest_requests_table.php +interest_requests,interest_requests_status_index,status,btree,False,,database/migrations/2026_01_01_000003_create_interest_requests_table.php +interest_requests,interest_requests_created_at_index,created_at,btree,False,,database/migrations/2026_01_01_000003_create_interest_requests_table.php +organizations,imz_organizations_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +organizations,imz_organizations_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +memberships,imz_memberships_user_id_idx,user_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +memberships,imz_memberships_organization_id_idx,organization_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +memberships,imz_memberships_user_id_organization_id_uq,"user_id,organization_id",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +memberships,imz_memberships_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +memberships,imz_memberships_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +consents,imz_consents_user_id_idx,user_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +consents,imz_consents_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +consents,imz_consents_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +consents,imz_consents_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +documents,imz_documents_storage_key_uq,storage_key,btree,True,,database/migrations/2026_09_24_012000_investmoz_indexes.php +documents,imz_documents_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +documents,imz_documents_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +documents,imz_documents_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +geo_locations,imz_geo_locations_parent_id_idx,parent_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +geo_locations,imz_geo_locations_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +geo_locations,imz_geo_locations_location_type_code_uq,"location_type,code",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +geo_locations,imz_geo_locations_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +geo_geometries,imz_geo_geometries_geo_location_id_idx,geo_location_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +geo_geometries,imz_geo_geometries_geo_location_id_geometry_type_uq,"geo_location_id,geometry_type",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +geo_geometries,imz_geo_geometries_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +geo_geometries,imz_geo_geometries_geom_idx,geom,gist,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +geo_geometries,imz_geo_geometries_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +projects,imz_projects_code_uq,code,btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +projects,imz_projects_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +projects,imz_projects_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +projects,imz_projects_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +project_locations,imz_project_locations_project_id_idx,project_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +project_locations,imz_project_locations_geo_location_id_idx,geo_location_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +project_locations,imz_project_locations_project_id_geo_location_id_role_uq,"project_id,geo_location_id,role",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +project_locations,imz_project_locations_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +project_locations,imz_project_locations_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +economic_clusters,imz_economic_clusters_code_uq,code,btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +economic_clusters,imz_economic_clusters_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +economic_clusters,imz_economic_clusters_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +economic_clusters,imz_economic_clusters_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +value_chains,imz_value_chains_cluster_id_idx,cluster_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +value_chains,imz_value_chains_cluster_id_code_uq,"cluster_id,code",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +value_chains,imz_value_chains_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +value_chains,imz_value_chains_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +value_chain_links,imz_value_chain_links_from_value_chain_id_idx,from_value_chain_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +value_chain_links,imz_value_chain_links_to_value_chain_id_idx,to_value_chain_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +value_chain_links,imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c,"from_value_chain_id,to_value_chain_id,link_type",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +value_chain_links,imz_value_chain_links_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +value_chain_links,imz_value_chain_links_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +cluster_projects,imz_cluster_projects_cluster_id_idx,cluster_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +cluster_projects,imz_cluster_projects_project_id_idx,project_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +cluster_projects,imz_cluster_projects_cluster_id_project_id_uq,"cluster_id,project_id",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +cluster_projects,imz_cluster_projects_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +cluster_projects,imz_cluster_projects_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +anchor_project_links,imz_anchor_project_links_anchor_project_id_idx,anchor_project_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +anchor_project_links,imz_anchor_project_links_cluster_id_idx,cluster_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +anchor_project_links,imz_anchor_project_links_anchor_project_id_cluster_id_uq,"anchor_project_id,cluster_id",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +anchor_project_links,imz_anchor_project_links_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +anchor_project_links,imz_anchor_project_links_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +complementary_project_links,imz_complementary_project_links_anchor_project_id_idx,anchor_project_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +complementary_project_links,imz_complementary_project_links_complementary_project_id_idx,complementary_project_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +complementary_project_links,imz_complementary_project_links_anchor_project_id_co_90eddcd25a,"anchor_project_id,complementary_project_id,relation_type",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +complementary_project_links,imz_complementary_project_links_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +complementary_project_links,imz_complementary_project_links_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +development_corridors,imz_development_corridors_code_uq,code,btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +development_corridors,imz_development_corridors_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +development_corridors,imz_development_corridors_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +development_corridors,imz_development_corridors_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_locations,imz_corridor_locations_corridor_id_idx,corridor_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_locations,imz_corridor_locations_geo_location_id_idx,geo_location_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_locations,imz_corridor_locations_corridor_id_geo_location_id_uq,"corridor_id,geo_location_id",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_locations,imz_corridor_locations_corridor_id_sequence_no_uq,"corridor_id,sequence_no",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_locations,imz_corridor_locations_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_locations,imz_corridor_locations_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_projects,imz_corridor_projects_corridor_id_idx,corridor_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_projects,imz_corridor_projects_project_id_idx,project_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_projects,imz_corridor_projects_corridor_id_project_id_uq,"corridor_id,project_id",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_projects,imz_corridor_projects_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_projects,imz_corridor_projects_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_clusters,imz_corridor_clusters_corridor_id_idx,corridor_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_clusters,imz_corridor_clusters_cluster_id_idx,cluster_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_clusters,imz_corridor_clusters_corridor_id_cluster_id_uq,"corridor_id,cluster_id",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_clusters,imz_corridor_clusters_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +corridor_clusters,imz_corridor_clusters_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +investors,imz_investors_organization_id_idx,organization_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +investors,imz_investors_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +investors,imz_investors_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +investors,imz_investors_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +contacts,imz_contacts_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +contacts,imz_contacts_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +investor_contacts,imz_investor_contacts_investor_id_idx,investor_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +investor_contacts,imz_investor_contacts_contact_id_idx,contact_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +investor_contacts,imz_investor_contacts_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +leads,imz_leads_investor_id_idx,investor_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +leads,imz_leads_owner_user_id_idx,owner_user_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +leads,imz_leads_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +leads,imz_leads_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +leads,imz_leads_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +crm_opportunities,imz_crm_opportunities_lead_id_idx,lead_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +crm_opportunities,imz_crm_opportunities_project_id_idx,project_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +crm_opportunities,imz_crm_opportunities_owner_user_id_idx,owner_user_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +crm_opportunities,imz_crm_opportunities_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +crm_opportunities,imz_crm_opportunities_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +activities,imz_activities_investor_id_idx,investor_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +activities,imz_activities_opportunity_id_idx,opportunity_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +activities,imz_activities_actor_user_id_idx,actor_user_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +activities,imz_activities_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +activities,imz_activities_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +tasks,imz_tasks_assignee_user_id_idx,assignee_user_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +tasks,imz_tasks_opportunity_id_idx,opportunity_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +tasks,imz_tasks_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +tasks,imz_tasks_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +tasks,imz_tasks_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +investor_journeys,imz_investor_journeys_investor_id_idx,investor_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +investor_journeys,imz_investor_journeys_opportunity_id_idx,opportunity_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +investor_journeys,imz_investor_journeys_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +investor_journeys,imz_investor_journeys_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +investor_journeys,imz_investor_journeys_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +journey_steps,imz_journey_steps_journey_id_idx,journey_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +journey_steps,imz_journey_steps_journey_id_sequence_no_uq,"journey_id,sequence_no",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +journey_steps,imz_journey_steps_journey_id_step_code_uq,"journey_id,step_code",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +journey_steps,imz_journey_steps_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +journey_steps,imz_journey_steps_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +journey_steps,imz_journey_steps_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +submissions,imz_submissions_journey_step_id_idx,journey_step_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +submissions,imz_submissions_submitted_by_idx,submitted_by,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +submissions,imz_submissions_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +submissions,imz_submissions_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +submissions,imz_submissions_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +aftercare_plans,imz_aftercare_plans_investor_id_idx,investor_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +aftercare_plans,imz_aftercare_plans_project_id_idx,project_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +aftercare_plans,imz_aftercare_plans_owner_user_id_idx,owner_user_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +aftercare_plans,imz_aftercare_plans_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +aftercare_plans,imz_aftercare_plans_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +aftercare_plans,imz_aftercare_plans_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +constraints,imz_constraints_aftercare_plan_id_idx,aftercare_plan_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +constraints,imz_constraints_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +constraints,imz_constraints_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +constraints,imz_constraints_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +escalations,imz_escalations_constraint_id_idx,constraint_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +escalations,imz_escalations_escalated_by_idx,escalated_by,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +escalations,imz_escalations_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +escalations,imz_escalations_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +escalations,imz_escalations_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +skills,imz_skills_code_uq,code,btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +skills,imz_skills_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +skills,imz_skills_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +occupations,imz_occupations_code_uq,code,btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +occupations,imz_occupations_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +occupations,imz_occupations_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +training_needs,imz_training_needs_project_id_idx,project_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +training_needs,imz_training_needs_occupation_id_idx,occupation_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +training_needs,imz_training_needs_skill_id_idx,skill_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +training_needs,imz_training_needs_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +training_needs,imz_training_needs_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +training_providers,imz_training_providers_organization_id_idx,organization_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +training_providers,imz_training_providers_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +training_providers,imz_training_providers_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +training_providers,imz_training_providers_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +courses,imz_courses_provider_id_idx,provider_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +courses,imz_courses_provider_id_code_uq,"provider_id,code",btree,True,deleted_at IS NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +courses,imz_courses_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +courses,imz_courses_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +courses,imz_courses_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +education_opportunities,imz_education_opportunities_course_id_idx,course_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +education_opportunities,imz_education_opportunities_geo_location_id_idx,geo_location_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +education_opportunities,imz_education_opportunities_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +education_opportunities,imz_education_opportunities_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +education_opportunities,imz_education_opportunities_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +project_documents,imz_project_documents_project_id_idx,project_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +project_documents,imz_project_documents_document_id_idx,document_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +investor_documents,imz_investor_documents_investor_id_idx,investor_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +investor_documents,imz_investor_documents_document_id_idx,document_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +journey_documents,imz_journey_documents_journey_id_idx,journey_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +journey_documents,imz_journey_documents_document_id_idx,document_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +aftercare_documents,imz_aftercare_documents_aftercare_plan_id_idx,aftercare_plan_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +aftercare_documents,imz_aftercare_documents_document_id_idx,document_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +education_documents,imz_education_documents_education_opportunity_id_idx,education_opportunity_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +education_documents,imz_education_documents_document_id_idx,document_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +audit_events,imz_audit_events_actor_user_id_idx,actor_user_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +audit_events,imz_audit_events_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +outbox_events,imz_outbox_events_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +outbox_events,imz_outbox_events_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +outbox_events,imz_outbox_events_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +inbox_events,imz_inbox_events_message_id_uq,message_id,btree,True,,database/migrations/2026_09_24_012000_investmoz_indexes.php +inbox_events,imz_inbox_events_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +inbox_events,imz_inbox_events_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +inbox_events,imz_inbox_events_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +sync_runs,imz_sync_runs_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +sync_runs,imz_sync_runs_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +sync_runs,imz_sync_runs_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +reconciliation_issues,imz_reconciliation_issues_sync_run_id_idx,sync_run_id,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +reconciliation_issues,imz_reconciliation_issues_source_system_external_id_uq,"source_system,external_id",btree,True,external_id IS NOT NULL,database/migrations/2026_09_24_012000_investmoz_indexes.php +reconciliation_issues,imz_reconciliation_issues_updated_at_idx,updated_at,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +reconciliation_issues,imz_reconciliation_issues_status_idx,status,btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +geo_geometries,imz_geo_geometries_properties_idx,properties,gin,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +projects,imz_projects_metadata_idx,metadata,gin,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +submissions,imz_submissions_payload_idx,payload,gin,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +audit_events,imz_audit_events_after_data_idx,after_data,gin,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +outbox_events,imz_outbox_events_status_available_at_idx,"status,available_at",btree,False,status = 'pending',database/migrations/2026_09_24_012000_investmoz_indexes.php +reconciliation_issues,imz_reconciliation_issues_sync_run_id_status_idx,"sync_run_id,status",btree,False,,database/migrations/2026_09_24_012000_investmoz_indexes.php +users,users_pkey,id,btree,True,,database/migrations/0001_01_01_000000_create_users_table.php +password_reset_tokens,password_reset_tokens_pkey,email,btree,True,,database/migrations/0001_01_01_000000_create_users_table.php +sessions,sessions_pkey,id,btree,True,,database/migrations/0001_01_01_000000_create_users_table.php +cache,cache_pkey,key,btree,True,,database/migrations/0001_01_01_000001_create_cache_table.php +cache_locks,cache_locks_pkey,key,btree,True,,database/migrations/0001_01_01_000001_create_cache_table.php +jobs,jobs_pkey,id,btree,True,,database/migrations/0001_01_01_000002_create_jobs_table.php +job_batches,job_batches_pkey,id,btree,True,,database/migrations/0001_01_01_000002_create_jobs_table.php +failed_jobs,failed_jobs_pkey,id,btree,True,,database/migrations/0001_01_01_000002_create_jobs_table.php +passkeys,passkeys_pkey,id,btree,True,,database/migrations/2024_01_01_000000_create_passkeys_table.php +sectors,sectors_pkey,id,btree,True,,database/migrations/2026_01_01_000001_create_sectors_table.php +opportunities,opportunities_pkey,id,btree,True,,database/migrations/2026_01_01_000002_create_opportunities_table.php +interest_requests,interest_requests_pkey,id,btree,True,,database/migrations/2026_01_01_000003_create_interest_requests_table.php +organizations,imz_organizations_pk,id,btree,True,,database/migrations/2026_09_24_001000_investmoz_identity.php +memberships,imz_memberships_pk,id,btree,True,,database/migrations/2026_09_24_001000_investmoz_identity.php +consents,imz_consents_pk,id,btree,True,,database/migrations/2026_09_24_001000_investmoz_identity.php +documents,imz_documents_pk,id,btree,True,,database/migrations/2026_09_24_002000_investmoz_content.php +geo_locations,imz_geo_locations_pk,id,btree,True,,database/migrations/2026_09_24_003000_investmoz_geography.php +geo_geometries,imz_geo_geometries_pk,id,btree,True,,database/migrations/2026_09_24_003000_investmoz_geography.php +projects,imz_projects_pk,id,btree,True,,database/migrations/2026_09_24_004000_investmoz_projects.php +project_locations,imz_project_locations_pk,id,btree,True,,database/migrations/2026_09_24_004000_investmoz_projects.php +economic_clusters,imz_economic_clusters_pk,id,btree,True,,database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chains,imz_value_chains_pk,id,btree,True,,database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chain_links,imz_value_chain_links_pk,id,btree,True,,database/migrations/2026_09_24_005000_investmoz_clusters.php +cluster_projects,imz_cluster_projects_pk,id,btree,True,,database/migrations/2026_09_24_005000_investmoz_clusters.php +anchor_project_links,imz_anchor_project_links_pk,id,btree,True,,database/migrations/2026_09_24_005000_investmoz_clusters.php +complementary_project_links,imz_complementary_project_links_pk,id,btree,True,,database/migrations/2026_09_24_005000_investmoz_clusters.php +development_corridors,imz_development_corridors_pk,id,btree,True,,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_locations,imz_corridor_locations_pk,id,btree,True,,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_projects,imz_corridor_projects_pk,id,btree,True,,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_clusters,imz_corridor_clusters_pk,id,btree,True,,database/migrations/2026_09_24_006000_investmoz_corridors.php +investors,imz_investors_pk,id,btree,True,,database/migrations/2026_09_24_007000_investmoz_crm.php +contacts,imz_contacts_pk,id,btree,True,,database/migrations/2026_09_24_007000_investmoz_crm.php +investor_contacts,imz_investor_contacts_pk,"investor_id,contact_id",btree,True,,database/migrations/2026_09_24_007000_investmoz_crm.php +leads,imz_leads_pk,id,btree,True,,database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,imz_crm_opportunities_pk,id,btree,True,,database/migrations/2026_09_24_007000_investmoz_crm.php +activities,imz_activities_pk,id,btree,True,,database/migrations/2026_09_24_007000_investmoz_crm.php +tasks,imz_tasks_pk,id,btree,True,,database/migrations/2026_09_24_007000_investmoz_crm.php +investor_journeys,imz_investor_journeys_pk,id,btree,True,,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +journey_steps,imz_journey_steps_pk,id,btree,True,,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +submissions,imz_submissions_pk,id,btree,True,,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +aftercare_plans,imz_aftercare_plans_pk,id,btree,True,,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +constraints,imz_constraints_pk,id,btree,True,,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +escalations,imz_escalations_pk,id,btree,True,,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +skills,imz_skills_pk,id,btree,True,,database/migrations/2026_09_24_009000_investmoz_education.php +occupations,imz_occupations_pk,id,btree,True,,database/migrations/2026_09_24_009000_investmoz_education.php +training_needs,imz_training_needs_pk,id,btree,True,,database/migrations/2026_09_24_009000_investmoz_education.php +training_providers,imz_training_providers_pk,id,btree,True,,database/migrations/2026_09_24_009000_investmoz_education.php +courses,imz_courses_pk,id,btree,True,,database/migrations/2026_09_24_009000_investmoz_education.php +education_opportunities,imz_education_opportunities_pk,id,btree,True,,database/migrations/2026_09_24_009000_investmoz_education.php +project_documents,imz_project_documents_pk,"project_id,document_id",btree,True,,database/migrations/2026_09_24_009500_investmoz_document_links.php +investor_documents,imz_investor_documents_pk,"investor_id,document_id",btree,True,,database/migrations/2026_09_24_009500_investmoz_document_links.php +journey_documents,imz_journey_documents_pk,"journey_id,document_id",btree,True,,database/migrations/2026_09_24_009500_investmoz_document_links.php +aftercare_documents,imz_aftercare_documents_pk,"aftercare_plan_id,document_id",btree,True,,database/migrations/2026_09_24_009500_investmoz_document_links.php +education_documents,imz_education_documents_pk,"education_opportunity_id,document_id",btree,True,,database/migrations/2026_09_24_009500_investmoz_document_links.php +audit_events,imz_audit_events_pk,id,btree,True,,database/migrations/2026_09_24_010000_investmoz_governance_ai.php +outbox_events,imz_outbox_events_pk,id,btree,True,,database/migrations/2026_09_24_011000_investmoz_integrations.php +inbox_events,imz_inbox_events_pk,id,btree,True,,database/migrations/2026_09_24_011000_investmoz_integrations.php +sync_runs,imz_sync_runs_pk,id,btree,True,,database/migrations/2026_09_24_011000_investmoz_integrations.php +reconciliation_issues,imz_reconciliation_issues_pk,id,btree,True,,database/migrations/2026_09_24_011000_investmoz_integrations.php diff --git a/docs/investmoz/legacy-schema.json b/docs/investmoz/legacy-schema.json new file mode 100644 index 00000000..76138266 --- /dev/null +++ b/docs/investmoz/legacy-schema.json @@ -0,0 +1,1549 @@ +{ + "tables": [ + { + "name": "users", + "domain": "Aplicação existente", + "purpose": "Autenticação Laravel/Fortify", + "columns": [ + { + "name": "id", + "type": "bigint", + "nullable": false, + "default": "nextval('users_id_seq'::regclass)", + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "id()", + "description": "id" + }, + { + "name": "name", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "string('name')", + "description": "name" + }, + { + "name": "email", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "string('email')->unique()", + "description": "email" + }, + { + "name": "email_verified_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "timestamp('email_verified_at')->nullable()", + "description": "email verified at" + }, + { + "name": "password", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "string('password')", + "description": "password" + }, + { + "name": "remember_token", + "type": "character varying(100)", + "nullable": true, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "rememberToken()", + "description": "remember token" + }, + { + "name": "created_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "timestamps()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "timestamps()", + "description": "updated at" + }, + { + "name": "two_factor_secret", + "type": "text", + "nullable": true, + "default": null, + "migration": "database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php", + "definition": "text('two_factor_secret')->after('password')->nullable()", + "description": "two factor secret" + }, + { + "name": "two_factor_recovery_codes", + "type": "text", + "nullable": true, + "default": null, + "migration": "database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php", + "definition": "text('two_factor_recovery_codes')->after('two_factor_secret')->nullable()", + "description": "two factor recovery codes" + }, + { + "name": "two_factor_confirmed_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php", + "definition": "timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable()", + "description": "two factor confirmed at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/0001_01_01_000000_create_users_table.php" + }, + { + "name": "password_reset_tokens", + "domain": "Aplicação existente", + "purpose": "Recuperação de palavra-passe", + "columns": [ + { + "name": "email", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "string('email')->primary()", + "description": "email" + }, + { + "name": "token", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "string('token')", + "description": "token" + }, + { + "name": "created_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "timestamp('created_at')->nullable()", + "description": "created at" + } + ], + "pk": [ + "email" + ], + "migration": "database/migrations/0001_01_01_000000_create_users_table.php" + }, + { + "name": "sessions", + "domain": "Aplicação existente", + "purpose": "Sessões Laravel", + "columns": [ + { + "name": "id", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "string('id')->primary()", + "description": "id" + }, + { + "name": "user_id", + "type": "bigint", + "nullable": true, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "foreignId('user_id')->nullable()->index()", + "description": "user id" + }, + { + "name": "ip_address", + "type": "character varying(45)", + "nullable": true, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "string('ip_address', 45)->nullable()", + "description": "ip address" + }, + { + "name": "user_agent", + "type": "text", + "nullable": true, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "text('user_agent')->nullable()", + "description": "user agent" + }, + { + "name": "payload", + "type": "text", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "longText('payload')", + "description": "payload" + }, + { + "name": "last_activity", + "type": "integer", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "definition": "integer('last_activity')->index()", + "description": "last activity" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/0001_01_01_000000_create_users_table.php" + }, + { + "name": "cache", + "domain": "Aplicação existente", + "purpose": "Cache Laravel", + "columns": [ + { + "name": "key", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000001_create_cache_table.php", + "definition": "string('key')->primary()", + "description": "key" + }, + { + "name": "value", + "type": "text", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000001_create_cache_table.php", + "definition": "mediumText('value')", + "description": "value" + }, + { + "name": "expiration", + "type": "bigint", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000001_create_cache_table.php", + "definition": "bigInteger('expiration')->index()", + "description": "expiration" + } + ], + "pk": [ + "key" + ], + "migration": "database/migrations/0001_01_01_000001_create_cache_table.php" + }, + { + "name": "cache_locks", + "domain": "Aplicação existente", + "purpose": "Bloqueios de cache", + "columns": [ + { + "name": "key", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000001_create_cache_table.php", + "definition": "string('key')->primary()", + "description": "key" + }, + { + "name": "owner", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000001_create_cache_table.php", + "definition": "string('owner')", + "description": "owner" + }, + { + "name": "expiration", + "type": "bigint", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000001_create_cache_table.php", + "definition": "bigInteger('expiration')->index()", + "description": "expiration" + } + ], + "pk": [ + "key" + ], + "migration": "database/migrations/0001_01_01_000001_create_cache_table.php" + }, + { + "name": "jobs", + "domain": "Aplicação existente", + "purpose": "Fila Laravel", + "columns": [ + { + "name": "id", + "type": "bigint", + "nullable": false, + "default": "nextval('jobs_id_seq'::regclass)", + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "id()", + "description": "id" + }, + { + "name": "queue", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "string('queue')->index()", + "description": "queue" + }, + { + "name": "payload", + "type": "text", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "longText('payload')", + "description": "payload" + }, + { + "name": "attempts", + "type": "smallint", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "unsignedSmallInteger('attempts')", + "description": "attempts" + }, + { + "name": "reserved_at", + "type": "integer", + "nullable": true, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "unsignedInteger('reserved_at')->nullable()", + "description": "reserved at" + }, + { + "name": "available_at", + "type": "integer", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "unsignedInteger('available_at')", + "description": "available at" + }, + { + "name": "created_at", + "type": "integer", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "unsignedInteger('created_at')", + "description": "created at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php" + }, + { + "name": "job_batches", + "domain": "Aplicação existente", + "purpose": "Lotes de jobs", + "columns": [ + { + "name": "id", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "string('id')->primary()", + "description": "id" + }, + { + "name": "name", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "string('name')", + "description": "name" + }, + { + "name": "total_jobs", + "type": "integer", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "integer('total_jobs')", + "description": "total jobs" + }, + { + "name": "pending_jobs", + "type": "integer", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "integer('pending_jobs')", + "description": "pending jobs" + }, + { + "name": "failed_jobs", + "type": "integer", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "integer('failed_jobs')", + "description": "failed jobs" + }, + { + "name": "failed_job_ids", + "type": "text", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "longText('failed_job_ids')", + "description": "failed job ids" + }, + { + "name": "options", + "type": "text", + "nullable": true, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "mediumText('options')->nullable()", + "description": "options" + }, + { + "name": "cancelled_at", + "type": "integer", + "nullable": true, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "integer('cancelled_at')->nullable()", + "description": "cancelled at" + }, + { + "name": "created_at", + "type": "integer", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "integer('created_at')", + "description": "created at" + }, + { + "name": "finished_at", + "type": "integer", + "nullable": true, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "integer('finished_at')->nullable()", + "description": "finished at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php" + }, + { + "name": "failed_jobs", + "domain": "Aplicação existente", + "purpose": "Falhas de jobs", + "columns": [ + { + "name": "id", + "type": "bigint", + "nullable": false, + "default": "nextval('failed_jobs_id_seq'::regclass)", + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "id()", + "description": "id" + }, + { + "name": "uuid", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "string('uuid')->unique()", + "description": "uuid" + }, + { + "name": "connection", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "string('connection')", + "description": "connection" + }, + { + "name": "queue", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "string('queue')", + "description": "queue" + }, + { + "name": "payload", + "type": "text", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "longText('payload')", + "description": "payload" + }, + { + "name": "exception", + "type": "text", + "nullable": false, + "default": null, + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "longText('exception')", + "description": "exception" + }, + { + "name": "failed_at", + "type": "timestamp(0) without time zone", + "nullable": false, + "default": "CURRENT_TIMESTAMP", + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "definition": "timestamp('failed_at')->useCurrent()", + "description": "failed at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php" + }, + { + "name": "passkeys", + "domain": "Aplicação existente", + "purpose": "Credenciais passkey", + "columns": [ + { + "name": "id", + "type": "bigint", + "nullable": false, + "default": "nextval('passkeys_id_seq'::regclass)", + "migration": "database/migrations/2024_01_01_000000_create_passkeys_table.php", + "definition": "id()", + "description": "id" + }, + { + "name": "user_id", + "type": "bigint", + "nullable": false, + "default": null, + "migration": "database/migrations/2024_01_01_000000_create_passkeys_table.php", + "definition": "foreignId('user_id')->constrained()->cascadeOnDelete()", + "description": "user id" + }, + { + "name": "name", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/2024_01_01_000000_create_passkeys_table.php", + "definition": "string('name')", + "description": "name" + }, + { + "name": "credential_id", + "type": "character varying(255)", + "nullable": false, + "default": null, + "migration": "database/migrations/2024_01_01_000000_create_passkeys_table.php", + "definition": "string('credential_id')->unique()", + "description": "credential id" + }, + { + "name": "credential", + "type": "json", + "nullable": false, + "default": null, + "migration": "database/migrations/2024_01_01_000000_create_passkeys_table.php", + "definition": "json('credential')", + "description": "credential" + }, + { + "name": "last_used_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2024_01_01_000000_create_passkeys_table.php", + "definition": "timestamp('last_used_at')->nullable()", + "description": "last used at" + }, + { + "name": "created_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2024_01_01_000000_create_passkeys_table.php", + "definition": "timestamps()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2024_01_01_000000_create_passkeys_table.php", + "definition": "timestamps()", + "description": "updated at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2024_01_01_000000_create_passkeys_table.php" + }, + { + "name": "sectors", + "domain": "Aplicação existente", + "purpose": "Sectores do catálogo público", + "columns": [ + { + "name": "id", + "type": "bigint", + "nullable": false, + "default": "nextval('sectors_id_seq'::regclass)", + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "definition": "id()", + "description": "id" + }, + { + "name": "name", + "type": "character varying(150)", + "nullable": false, + "default": null, + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "definition": "string('name', 150)", + "description": "name" + }, + { + "name": "slug", + "type": "character varying(180)", + "nullable": false, + "default": null, + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "definition": "string('slug', 180)->unique()", + "description": "slug" + }, + { + "name": "short_description", + "type": "character varying(255)", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "definition": "string('short_description', 255)->nullable()", + "description": "short description" + }, + { + "name": "description", + "type": "text", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "definition": "text('description')->nullable()", + "description": "description" + }, + { + "name": "icon", + "type": "character varying(100)", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "definition": "string('icon', 100)->nullable()", + "description": "icon" + }, + { + "name": "sort_order", + "type": "integer", + "nullable": false, + "default": "0", + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "definition": "unsignedInteger('sort_order')->default(0)", + "description": "sort order" + }, + { + "name": "is_active", + "type": "boolean", + "nullable": false, + "default": "true", + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "definition": "boolean('is_active')->default(true)", + "description": "is active" + }, + { + "name": "published_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "definition": "timestamp('published_at')->nullable()", + "description": "published at" + }, + { + "name": "created_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "definition": "timestamps()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "definition": "timestamps()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "definition": "softDeletes()", + "description": "deleted at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php" + }, + { + "name": "opportunities", + "domain": "Aplicação existente", + "purpose": "Catálogo público de oportunidades", + "columns": [ + { + "name": "id", + "type": "bigint", + "nullable": false, + "default": "nextval('opportunities_id_seq'::regclass)", + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "id()", + "description": "id" + }, + { + "name": "sector_id", + "type": "bigint", + "nullable": false, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "foreignId('sector_id')\n ->constrained('sectors')\n ->cascadeOnUpdate()\n ->restrictOnDelete()", + "description": "sector id" + }, + { + "name": "reference", + "type": "character varying(80)", + "nullable": false, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "string('reference', 80)->unique()", + "description": "reference" + }, + { + "name": "title", + "type": "character varying(200)", + "nullable": false, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "string('title', 200)", + "description": "title" + }, + { + "name": "slug", + "type": "character varying(220)", + "nullable": false, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "string('slug', 220)->unique()", + "description": "slug" + }, + { + "name": "summary", + "type": "character varying(500)", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "string('summary', 500)->nullable()", + "description": "summary" + }, + { + "name": "description", + "type": "text", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "longText('description')->nullable()", + "description": "description" + }, + { + "name": "province", + "type": "character varying(100)", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "string('province', 100)->nullable()", + "description": "province" + }, + { + "name": "district", + "type": "character varying(100)", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "string('district', 100)->nullable()", + "description": "district" + }, + { + "name": "promoter_name", + "type": "character varying(200)", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "string('promoter_name', 200)->nullable()", + "description": "promoter name" + }, + { + "name": "promoter_type", + "type": "character varying(100)", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "string('promoter_type', 100)->nullable()", + "description": "promoter type" + }, + { + "name": "maturity_level", + "type": "smallint", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "unsignedTinyInteger('maturity_level')->nullable()", + "description": "maturity level" + }, + { + "name": "maturity_label", + "type": "character varying(120)", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "string('maturity_label', 120)->nullable()", + "description": "maturity label" + }, + { + "name": "estimated_investment_amount", + "type": "numeric(18,2)", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "decimal('estimated_investment_amount', 18, 2)->nullable()", + "description": "estimated investment amount" + }, + { + "name": "estimated_investment_currency", + "type": "character varying(10)", + "nullable": false, + "default": "'USD'", + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "string('estimated_investment_currency', 10)->default('USD')", + "description": "estimated investment currency" + }, + { + "name": "financing_modality", + "type": "character varying(120)", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "string('financing_modality', 120)->nullable()", + "description": "financing modality" + }, + { + "name": "expected_impact", + "type": "text", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "text('expected_impact')->nullable()", + "description": "expected impact" + }, + { + "name": "status", + "type": "character varying(50)", + "nullable": false, + "default": "'draft'", + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "string('status', 50)->default('draft')", + "description": "status" + }, + { + "name": "is_featured", + "type": "boolean", + "nullable": false, + "default": "false", + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "boolean('is_featured')->default(false)", + "description": "is featured" + }, + { + "name": "published_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "timestamp('published_at')->nullable()", + "description": "published at" + }, + { + "name": "last_validated_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "timestamp('last_validated_at')->nullable()", + "description": "last validated at" + }, + { + "name": "created_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "timestamps()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "timestamps()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "definition": "softDeletes()", + "description": "deleted at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php" + }, + { + "name": "interest_requests", + "domain": "Aplicação existente", + "purpose": "Manifestações de interesse", + "columns": [ + { + "name": "id", + "type": "bigint", + "nullable": false, + "default": "nextval('interest_requests_id_seq'::regclass)", + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "id()", + "description": "id" + }, + { + "name": "opportunity_id", + "type": "bigint", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "foreignId('opportunity_id')\n ->nullable()\n ->constrained('opportunities')\n ->cascadeOnUpdate()\n ->nullOnDelete()", + "description": "opportunity id" + }, + { + "name": "reference", + "type": "character varying(80)", + "nullable": false, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "string('reference', 80)->unique()", + "description": "reference" + }, + { + "name": "full_name", + "type": "character varying(150)", + "nullable": false, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "string('full_name', 150)", + "description": "full name" + }, + { + "name": "email", + "type": "character varying(150)", + "nullable": false, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "string('email', 150)", + "description": "email" + }, + { + "name": "phone", + "type": "character varying(30)", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "string('phone', 30)->nullable()", + "description": "phone" + }, + { + "name": "investor_type", + "type": "character varying(80)", + "nullable": false, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "string('investor_type', 80)", + "description": "investor type" + }, + { + "name": "country", + "type": "character varying(100)", + "nullable": false, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "string('country', 100)", + "description": "country" + }, + { + "name": "sector_interest", + "type": "character varying(150)", + "nullable": false, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "string('sector_interest', 150)", + "description": "sector interest" + }, + { + "name": "opportunity_reference", + "type": "character varying(80)", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "string('opportunity_reference', 80)->nullable()", + "description": "opportunity reference" + }, + { + "name": "investment_range", + "type": "character varying(80)", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "string('investment_range', 80)->nullable()", + "description": "investment range" + }, + { + "name": "message", + "type": "text", + "nullable": false, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "text('message')", + "description": "message" + }, + { + "name": "privacy_consent", + "type": "boolean", + "nullable": false, + "default": "false", + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "boolean('privacy_consent')->default(false)", + "description": "privacy consent" + }, + { + "name": "consented_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "timestamp('consented_at')->nullable()", + "description": "consented at" + }, + { + "name": "status", + "type": "character varying(50)", + "nullable": false, + "default": "'received'", + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "string('status', 50)->default('received')", + "description": "status" + }, + { + "name": "source_page", + "type": "character varying(255)", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "string('source_page', 255)->nullable()", + "description": "source page" + }, + { + "name": "ip_address", + "type": "inet", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "ipAddress('ip_address')->nullable()", + "description": "ip address" + }, + { + "name": "user_agent", + "type": "text", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "text('user_agent')->nullable()", + "description": "user agent" + }, + { + "name": "metadata", + "type": "json", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "json('metadata')->nullable()", + "description": "metadata" + }, + { + "name": "reviewed_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "timestamp('reviewed_at')->nullable()", + "description": "reviewed at" + }, + { + "name": "reviewed_by", + "type": "bigint", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete()", + "description": "reviewed by" + }, + { + "name": "created_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "timestamps()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "timestamps()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamp(0) without time zone", + "nullable": true, + "default": null, + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "definition": "softDeletes()", + "description": "deleted at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php" + } + ], + "relationships": [ + { + "table": "passkeys", + "column": "user_id", + "target": "users", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "NO ACTION", + "name": "passkeys_user_id_foreign", + "migration": "database/migrations/2024_01_01_000000_create_passkeys_table.php", + "logical": false + }, + { + "table": "opportunities", + "column": "sector_id", + "target": "sectors", + "target_column": "id", + "nullable": false, + "on_delete": "RESTRICT", + "on_update": "CASCADE", + "name": "opportunities_sector_id_foreign", + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "logical": false + }, + { + "table": "interest_requests", + "column": "opportunity_id", + "target": "opportunities", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "interest_requests_opportunity_id_foreign", + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "logical": false + }, + { + "table": "interest_requests", + "column": "reviewed_by", + "target": "users", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "name": "interest_requests_reviewed_by_foreign", + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "logical": false + } + ], + "indexes": [ + { + "table": "users", + "columns": [ + "email" + ], + "unique": true, + "method": "btree", + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "name": "users_email_unique", + "predicate": null + }, + { + "table": "sessions", + "columns": [ + "user_id" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "name": "sessions_user_id_index", + "predicate": null + }, + { + "table": "sessions", + "columns": [ + "last_activity" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/0001_01_01_000000_create_users_table.php", + "name": "sessions_last_activity_index", + "predicate": null + }, + { + "table": "cache", + "columns": [ + "expiration" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/0001_01_01_000001_create_cache_table.php", + "name": "cache_expiration_index", + "predicate": null + }, + { + "table": "cache_locks", + "columns": [ + "expiration" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/0001_01_01_000001_create_cache_table.php", + "name": "cache_locks_expiration_index", + "predicate": null + }, + { + "table": "jobs", + "columns": [ + "queue" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "name": "jobs_queue_index", + "predicate": null + }, + { + "table": "failed_jobs", + "columns": [ + "uuid" + ], + "unique": true, + "method": "btree", + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "name": "failed_jobs_uuid_unique", + "predicate": null + }, + { + "table": "failed_jobs", + "columns": [ + "connection", + "queue", + "failed_at" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/0001_01_01_000002_create_jobs_table.php", + "name": "failed_jobs_connection_queue_failed_at_index", + "predicate": null + }, + { + "table": "passkeys", + "columns": [ + "credential_id" + ], + "unique": true, + "method": "btree", + "migration": "database/migrations/2024_01_01_000000_create_passkeys_table.php", + "name": "passkeys_credential_id_unique", + "predicate": null + }, + { + "table": "passkeys", + "columns": [ + "user_id" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/2024_01_01_000000_create_passkeys_table.php", + "name": "passkeys_user_id_index", + "predicate": null + }, + { + "table": "sectors", + "columns": [ + "slug" + ], + "unique": true, + "method": "btree", + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "name": "sectors_slug_unique", + "predicate": null + }, + { + "table": "sectors", + "columns": [ + "slug" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "name": "sectors_slug_index", + "predicate": null + }, + { + "table": "sectors", + "columns": [ + "is_active" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/2026_01_01_000001_create_sectors_table.php", + "name": "sectors_is_active_index", + "predicate": null + }, + { + "table": "opportunities", + "columns": [ + "reference" + ], + "unique": true, + "method": "btree", + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "name": "opportunities_reference_unique", + "predicate": null + }, + { + "table": "opportunities", + "columns": [ + "slug" + ], + "unique": true, + "method": "btree", + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "name": "opportunities_slug_unique", + "predicate": null + }, + { + "table": "opportunities", + "columns": [ + "sector_id" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "name": "opportunities_sector_id_index", + "predicate": null + }, + { + "table": "opportunities", + "columns": [ + "reference" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "name": "opportunities_reference_index", + "predicate": null + }, + { + "table": "opportunities", + "columns": [ + "slug" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "name": "opportunities_slug_index", + "predicate": null + }, + { + "table": "opportunities", + "columns": [ + "status" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "name": "opportunities_status_index", + "predicate": null + }, + { + "table": "opportunities", + "columns": [ + "is_featured" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "name": "opportunities_is_featured_index", + "predicate": null + }, + { + "table": "opportunities", + "columns": [ + "province" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "name": "opportunities_province_index", + "predicate": null + }, + { + "table": "opportunities", + "columns": [ + "maturity_level" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/2026_01_01_000002_create_opportunities_table.php", + "name": "opportunities_maturity_level_index", + "predicate": null + }, + { + "table": "interest_requests", + "columns": [ + "reference" + ], + "unique": true, + "method": "btree", + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "name": "interest_requests_reference_unique", + "predicate": null + }, + { + "table": "interest_requests", + "columns": [ + "reference" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "name": "interest_requests_reference_index", + "predicate": null + }, + { + "table": "interest_requests", + "columns": [ + "email" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "name": "interest_requests_email_index", + "predicate": null + }, + { + "table": "interest_requests", + "columns": [ + "status" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "name": "interest_requests_status_index", + "predicate": null + }, + { + "table": "interest_requests", + "columns": [ + "created_at" + ], + "unique": false, + "method": "btree", + "migration": "database/migrations/2026_01_01_000003_create_interest_requests_table.php", + "name": "interest_requests_created_at_index", + "predicate": null + } + ] +} diff --git a/docs/investmoz/management.md b/docs/investmoz/management.md new file mode 100644 index 00000000..b636c7e3 --- /dev/null +++ b/docs/investmoz/management.md @@ -0,0 +1,95 @@ +# Gestão do portal + +## Acesso + +A página `/acesso-gestao`, ligada no rodapé do portal, permite configurar a conta +mais tarde. Apresenta entrada/registo, verificação pendente ou acesso à gestão +conforme a sessão. Não cria contas nem revela a lista de emails autorizados. +A verificação de email está activa no modelo User; o envio real depende da +configuração de correio do ambiente. Nenhuma conta é verificada automaticamente. + +Configurar `PORTAL_MANAGER_EMAILS` no `.env` com os emails autorizados, separados +por vírgula. As contas devem existir e ter email verificado. A lista vazia bloqueia +o acesso de todos os utilizadores. O registo público não concede privilégios. +Após alterar a configuração, executar `php artisan config:clear` (ou reconstruir +o cache de configuração no ambiente de implantação). + +Esta lista limita o acesso à gestão local; não substitui a futura matriz de +papéis e permissões de todos os domínios InvestMoz. + +## Páginas + +- `/management/history`: histórico protegido de criação/edição de sectores, + oportunidades e revisão de pedidos. Guarda o responsável e os campos alterados; + valores de textos livres e contactos não são copiados. + +- `/dashboard`: resumo para gestores com pedidos por estado, oportunidades + publicadas e em rascunho, sectores activos e os cinco pedidos mais recentes. + As contas sem autorização recebem apenas orientações de configuração, sem + indicadores internos. Os totais são consultados no carregamento da página. + +- `/management/interests`: pesquisa por referência, nome e email; filtro por estado; + paginação e consulta dos pedidos. A alteração de estado regista o utilizador + autenticado e a data da última revisão. +- `/management/sectors`: criação e edição de sectores, ordem e activação. +- `/management/opportunities`: criação e edição, maturidade, moeda e valor, + publicação, destaque e arquivo. Rascunhos e arquivos não aparecem na API pública. +- `/management/crm/leads`: leads, pesquisa, responsável, qualificação humana e tarefas. + A ficha do pedido permite convertê-lo em lead, com identificação explícita do + investidor e próxima acção obrigatória. Repetir a conversão abre o mesmo lead. +- `/management/crm/investors`: investidores e respectivos contactos. Um contacto + partilhado entre investidores tem uma única ficha; a alteração reflecte-se nos vínculos. +- `/management/projects`: cadastro de projectos, orçamento e calendário. +- `/management/clusters`: cadastro de clusters, associação de projectos e etapas + da cadeia de valor. Estes cadastros são internos; activar não publica nem aprova. + Na ficha do cluster também é possível ligar etapas, retirar ligações preservando + o histórico e gerir lacunas de investimento com localização, capacidade, + justificação, estado e chamada para acção. A localização é descritiva; a chamada + para acção ainda não constitui um formulário público de candidatura. + +O histórico inclui também as alterações efectuadas nestes módulos. A cobertura +actual e as funcionalidades ainda pendentes estão em [upgrade-progress.md](upgrade-progress.md). + +As rotas de alteração usam sessões e protecção CSRF do Laravel. O servidor valida +os dados e verifica a autorização em todas as rotas. Os formulários não aceitam +atribuição de privilégios. Não há eliminação definitiva de pedidos ou catálogo. +As alterações do catálogo invalidam o cache da página inicial nos dois idiomas. + +O catálogo administrativo permite pesquisar nome/título, slug e referência, +filtrar por estado e, nas oportunidades, por sector. Os filtros ficam no URL, +são preservados na paginação e podem ser limpos. Os indicadores do painel abrem +as listas filtradas correspondentes. A pesquisa não distingue maiúsculas de +minúsculas. Os testes de gestão incluem combinação de filtros, paginação e +rejeição de parâmetros inválidos (7 testes, 120 verificações). + +## Pedidos públicos + +As novas referências usam `IR-ANO-ULID`, sem depender da contagem de linhas. +As referências antigas continuam válidas. A restrição única da base mantém-se. +Referências de oportunidades inexistentes, retiradas, eliminadas ou com publicação +futura são rejeitadas com 422. Pedidos gerais podem omitir a oportunidade. +Mensagens de excepção com SQL e dados pessoais não são escritas no log deste endpoint. + +## Testes + +`tests/Feature/PortalManagementTest.php` cobre autorização, edição, publicação, +arquivo, precisão monetária, cache, revisão, validação e persistência real de pedidos. +Usar exclusivamente uma base descartável terminada em `_test`: os testes usam +`RefreshDatabase`. Nunca apontar os testes para a base `investemoz`. +O teste de envio conserva a validação DNS do email e requer resolução DNS; não envia email. + +Após a implementação do painel, passaram 49 testes PHP (290 verificações), +incluindo contagens reais, ausência de dados, limite e ordem dos pedidos recentes +e protecção dos indicadores contra acesso por contas sem autorização. + +Validação em 25/09/2026: 45 testes PHP (205 verificações) e 12 testes JavaScript +passaram. TypeScript, lint e build passaram. No Edge foram verificados login, +criação de sector, publicação e arquivo com consulta da API real, revisão de pedido +e persistência após recarregar. Os testes de escrita usaram PostgreSQL isolado na +porta 55440; os dados da aplicação não foram alterados. + +```powershell +# Configurar DB_HOST, DB_PORT, DB_USERNAME e DB_PASSWORD da instância isolada. +$env:DB_DATABASE = 'investmoz_test' +php vendor/bin/phpunit --filter PortalManagementTest --do-not-cache-result +``` diff --git a/docs/investmoz/objects.json b/docs/investmoz/objects.json new file mode 100644 index 00000000..bd529ef5 --- /dev/null +++ b/docs/investmoz/objects.json @@ -0,0 +1,363 @@ +{ + "schemas": [ + "public" + ], + "framework_owned_objects": [ + "migrations (Laravel)" + ], + "extension_owned_objects": "Objectos PostGIS pertencem à extensão; não são duplicados no dicionário aplicacional.", + "extensions": [ + "postgis", + "pgcrypto" + ], + "functions": [ + "CREATE OR REPLACE FUNCTION public.investmoz_set_updated_at() RETURNS trigger\nLANGUAGE plpgsql SET search_path = pg_catalog, public AS $$\nBEGIN\n NEW.updated_at = CURRENT_TIMESTAMP;\n RETURN NEW;\nEND;\n$$;", + "CREATE OR REPLACE FUNCTION public.investmoz_reject_audit_change() RETURNS trigger\nLANGUAGE plpgsql SET search_path = pg_catalog, public AS $$\nBEGIN\n RAISE EXCEPTION 'InvestMoz audit_events is append-only' USING ERRCODE = '42501';\nEND;\n$$;", + "CREATE OR REPLACE FUNCTION public.investmoz_protect_event_envelope() RETURNS trigger\nLANGUAGE plpgsql SET search_path = pg_catalog, public AS $$\nBEGIN\n IF TG_OP = 'DELETE' THEN\n RAISE EXCEPTION 'InvestMoz event deletion requires a reviewed retention migration' USING ERRCODE = '42501';\n END IF;\n IF (to_jsonb(NEW) - ARRAY['status','updated_at','published_at','processed_at','available_at'])\n IS DISTINCT FROM\n (to_jsonb(OLD) - ARRAY['status','updated_at','published_at','processed_at','available_at']) THEN\n RAISE EXCEPTION 'InvestMoz event envelope is immutable' USING ERRCODE = '42501';\n END IF;\n RETURN NEW;\nEND;\n$$;" + ], + "triggers": [ + { + "name": "imz_organizations_updated", + "table": "organizations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_memberships_updated", + "table": "memberships", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_consents_updated", + "table": "consents", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_documents_updated", + "table": "documents", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_geo_locations_updated", + "table": "geo_locations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_geo_geometries_updated", + "table": "geo_geometries", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_projects_updated", + "table": "projects", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_project_locations_updated", + "table": "project_locations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_economic_clusters_updated", + "table": "economic_clusters", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_value_chains_updated", + "table": "value_chains", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_value_chain_links_updated", + "table": "value_chain_links", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_cluster_projects_updated", + "table": "cluster_projects", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_anchor_project_links_updated", + "table": "anchor_project_links", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_complementary_project_links_updated", + "table": "complementary_project_links", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_development_corridors_updated", + "table": "development_corridors", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_corridor_locations_updated", + "table": "corridor_locations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_corridor_projects_updated", + "table": "corridor_projects", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_corridor_clusters_updated", + "table": "corridor_clusters", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_investors_updated", + "table": "investors", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_contacts_updated", + "table": "contacts", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_investor_contacts_updated", + "table": "investor_contacts", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_leads_updated", + "table": "leads", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_crm_opportunities_updated", + "table": "crm_opportunities", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_activities_updated", + "table": "activities", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_tasks_updated", + "table": "tasks", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_investor_journeys_updated", + "table": "investor_journeys", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_journey_steps_updated", + "table": "journey_steps", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_submissions_updated", + "table": "submissions", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_aftercare_plans_updated", + "table": "aftercare_plans", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_constraints_updated", + "table": "constraints", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_escalations_updated", + "table": "escalations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_skills_updated", + "table": "skills", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_occupations_updated", + "table": "occupations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_training_needs_updated", + "table": "training_needs", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_training_providers_updated", + "table": "training_providers", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_courses_updated", + "table": "courses", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_education_opportunities_updated", + "table": "education_opportunities", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_outbox_events_updated", + "table": "outbox_events", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_inbox_events_updated", + "table": "inbox_events", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_sync_runs_updated", + "table": "sync_runs", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_reconciliation_issues_updated", + "table": "reconciliation_issues", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_audit_events_immutable", + "table": "audit_events", + "event": "BEFORE UPDATE OR DELETE OR TRUNCATE", + "function": "investmoz_reject_audit_change", + "level": "STATEMENT" + }, + { + "name": "imz_outbox_events_envelope", + "table": "outbox_events", + "event": "BEFORE UPDATE OR DELETE", + "function": "investmoz_protect_event_envelope", + "level": "ROW" + }, + { + "name": "imz_outbox_events_no_truncate", + "table": "outbox_events", + "event": "BEFORE TRUNCATE", + "function": "investmoz_reject_audit_change", + "level": "STATEMENT" + }, + { + "name": "imz_inbox_events_envelope", + "table": "inbox_events", + "event": "BEFORE UPDATE OR DELETE", + "function": "investmoz_protect_event_envelope", + "level": "ROW" + }, + { + "name": "imz_inbox_events_no_truncate", + "table": "inbox_events", + "event": "BEFORE TRUNCATE", + "function": "investmoz_reject_audit_change", + "level": "STATEMENT" + } + ], + "views": [], + "materialized_views": [], + "custom_enums": [], + "generated_columns": [], + "sequences": [ + "users_id_seq", + "jobs_id_seq", + "failed_jobs_id_seq", + "passkeys_id_seq", + "sectors_id_seq", + "opportunities_id_seq", + "interest_requests_id_seq" + ], + "security_policies": [], + "audit": "append-only; writers must not own the tables or possess DDL privileges", + "logical_links_without_fk": [ + "sessions.user_id -> users.id", + "password_reset_tokens.email -> users.email", + "audit_events.entity_type/entity_id", + "outbox_events.aggregate_type/aggregate_id" + ] +} diff --git a/docs/investmoz/portfolio-journey.md b/docs/investmoz/portfolio-journey.md new file mode 100644 index 00000000..219a6299 --- /dev/null +++ b/docs/investmoz/portfolio-journey.md @@ -0,0 +1,43 @@ +# Carteira territorial e jornada do investidor + +Entrega de 26/09/2026. Cobertura parcial de RF-COR-001/002/003/007/008/013 e RF-EII-004/005/007, articulada com RF-CLU e as matrizes documentais. Não representa conclusão integral destes requisitos. + +## Conteúdo importado + +`database/data/investment-portfolio.json` transcreve as secções 4.3.4 (seis corredores), 4.5.2 (oito clusters), 6.1 e 6.6 (quinze projectos A01–A15) do documento de upgrade fornecido. Utilizada a extracção textual existente dos parágrafos 931–960, 1254–1285, 3432–3521 e 3741–3815. O DOCX estava aberto/bloqueado nesta sessão; não foi possível recalcular o hash do original. A extracção não foi usada como instrução operacional para executar os prompts contidos no documento. + +As pontuações 91, 89, 88, 77, 74, 73 e 72 são valores propostos na fonte, não um cálculo de scoring nem recomendações personalizadas. Financiamento é habilitador transversal sem pontuação inventada. Valores monetários e metas permanecem no texto indicativo; não foram transformados em orçamento oficial. Não foram inventados mapas, coordenadas, entidades responsáveis ou autorizações. + +`InvestmentPortfolioSeeder` cria cadastros e relações reutilizando `projects`, `economic_clusters`, `development_corridors`, `cluster_projects`, `corridor_projects` e `corridor_clusters`. Os IDs A01–A15 das matrizes determinam os vínculos a projectos. As correspondências sectoriais inequívocas dos corredores são mapeadas explicitamente; categorias genéricas não são transformadas automaticamente em novos clusters. O texto original de cada matriz permanece disponível para revisão. + +Importação transaccional, serializada e reexecutável: preserva edições humanas; colisões de códigos com registos de outra origem ou eliminados interrompem a operação. O seeder cria rascunhos. O comando `portfolio:import --approval="Referência da aprovação do proprietário"` publica apenas fichas documentais sem alterações humanas; repetir não duplica publicações ou auditoria nem restaura fichas arquivadas. Sem `--approval`, apenas importa. + +## Aprovação e execução local — 26/09/2026 + +O proprietário aprovou expressamente nesta sessão a inserção e execução da carteira. Essa decisão substitui a exigência anterior de segundo gestor exclusivamente na carteira de investimento. Um gestor já autorizado pode aprovar a própria ficha, registando a decisão. A importação por consola conserva a autorização do proprietário, sem inventar uma conta ou identidade de revisor. + +Aplicada `2026_09_26_140000_portfolio_owner_approval` e executado `portfolio:import --approval=...`. Confirmados na base local: **15 projectos, 8 clusters, 6 corredores, 29 fichas publicadas**, 17 relações corredor–projecto, 13 cluster–projecto e 20 corredor–cluster. Todos os quinze projectos pertencem a pelo menos um corredor. Os cadastros já existiam e foram conservados; foram publicadas as fichas e adicionados 29 eventos de auditoria. + +Backup e restauro verificado de 74 tabelas: `storage/app/private/portfolio-backup-20260926-162236/`, com manifesto e reconciliação `portfolio-verification.json`. As únicas diferenças de contagem são 29 eventos de auditoria e uma migration. A fonte e os valores documentais permanecem identificados; publicar a carteira não declara execução física ou financiamento dos projectos. + +## Operação + +- `/management/portfolio`: pesquisar e filtrar a matriz, editar fichas, consultar relações e preparar publicação. +- Publicação exige gestor autorizado e referência da decisão, ou aprovação expressa do proprietário na importação documental. Alterar uma ficha publicada retira-a imediatamente da consulta pública até nova aprovação. O sistema regista a decisão e não verifica por si a autenticidade de documentos institucionais. +- `/carteira`: consulta pública exclusivamente de fichas publicadas cujas entidades não estejam eliminadas; navegação bidireccional corredor–projecto, corredor–cluster e cluster–projecto. Evidência interna e IDs dos revisores não são serializados publicamente. +- `/investidor`: conta autenticada e email verificado, fichas guardadas, jornada única por utilizador/ficha, formulário com reutilização explícita do nome/email, papel, horizonte, mensagem, consentimento e protocolo. +- Uma relação de origem directa, validada no servidor, acompanha a navegação e o pedido. Uma sequência arbitrária de múltiplos corredores/clusters ainda não é preservada integralmente. +- Pedido, UUID de integração, evento da outbox e ligação à jornada ficam na mesma transacção. Uma jornada aceita uma manifestação: repetir o envio devolve o acompanhamento existente, sem criar outro pedido. Para rever o pedido após envio, o atendimento continua assistido; não existe edição pelo investidor. +- Pedidos aparecem na gestão existente. A conversão para lead exige responsável e próxima acção e conserva o investidor associado à jornada. Não são atribuídos mandatos de organização nem identidades institucionais automaticamente. + +O perfil individual é criado por ID de conta, sem tentar reconhecer identidades por coincidência de email. A associação a pessoas já existentes no CRM e a representação de organizações continuam a exigir reconciliação e gestão de mandatos. Não existe ligação automática a SuiteCRM; os eventos mantêm-se pendentes. O novo fluxo não agenda emails de notificação: o acompanhamento está no painel e na gestão de pedidos. + +## Validação e limites + +Testes funcionais verificam reimportação, aprovação do proprietário, publicação pelo próprio gestor, rejeição de contas sem privilégios, preservação de edições, relações dos quinze projectos, privacidade de rascunhos, retirada ao editar, isolamento entre contas, consentimento, contexto, publicação/eliminação, não duplicação e identidade na conversão para CRM. Nesta alteração: **119 testes, 1.107 verificações**, PHPStan, Pint, TypeScript e build aprovados. A verificação anterior em Edge cobriu corredor → âncora → painel → pedido → protocolo e largura móvel; não foi repetida visualmente nesta alteração. Fixtures permanecem apenas no PostgreSQL isolado. + +Continuam pendentes: cadastro GIS e nós territoriais, CRUD das relações de corredores, dossier exportável, comparação, versionamento completo dos conteúdos, publicação de mapas autorizados, planos e riscos, indicadores, competências/educação, governação interinstitucional, perfis organizacionais, cofre documental, tarefas/mensagens no painel, balcão único, aftercare, IA supervisionada e sincronização real do CRM. O CRUD existente de projectos/clusters e cadeias de valor permanece disponível separadamente. + +## Migração e reversão + +Migração aditiva `2026_09_25_130000_investment_portfolio_journeys`. Não modifica as migrations históricas. O rollback recusa eliminar dossiers ou contas existentes; preservar dados e preparar reconciliação antes de reverter o esquema. Para suspender divulgação, guardar a ficha como rascunho. Não executar `migrate:fresh` na base local ou de produção. diff --git a/docs/investmoz/public-portal-browser-validation.json b/docs/investmoz/public-portal-browser-validation.json new file mode 100644 index 00000000..cac155c6 --- /dev/null +++ b/docs/investmoz/public-portal-browser-validation.json @@ -0,0 +1,87 @@ +{ + "reports": [ + { + "path": "/", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Oportunidades reais de investimento em Moçambique, organizadas por sector." + }, + { + "path": "/oportunidades", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Oportunidades de investimento" + }, + { + "path": "/sectores", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Sectores Prioritários" + }, + { + "path": "/como-investir", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Como Investir / Procedimentos" + }, + { + "path": "/legislacao", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Legislação e Incentivos" + }, + { + "path": "/documentos", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Documentos e Formulários" + }, + { + "path": "/apoio", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Apoio ao Investidor" + }, + { + "path": "/parcerias", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Bolsa de Parcerias" + }, + { + "path": "/simulador-fiscal", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Simulador Fiscal" + }, + { + "path": "/formularios/empresa", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Formulário de empresa" + }, + { + "path": "/login", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Log in to your account" + } + ], + "simulation": "Sem redução: 100,00 MTnRedução simulada: 50,00 MTnTotal do cenário: 50,00 MTnBase × taxa ÷ 100 × (1 − redução ÷ 100). Não inclui outros impostos, deduções, escalões ou regras sectoriais.", + "desktop": { + "overflow": false, + "breadcrumb": true + }, + "errors": [] +} \ No newline at end of file diff --git a/docs/investmoz/public-portal-sections.md b/docs/investmoz/public-portal-sections.md new file mode 100644 index 00000000..c18f929a --- /dev/null +++ b/docs/investmoz/public-portal-sections.md @@ -0,0 +1,50 @@ +# Portal público — estrutura e serviços + +Implementação local de 27/09/2026, seguindo as sete áreas pedidas pelo proprietário. + +## Páginas e percursos + +| Área | Caminho e comportamento | +| --- | --- | +| Página inicial | `/`: cabeçalho, banner, sectores activos, Como Investir, oportunidades em destaque, documentos essenciais publicados, apoio e rodapé. | +| Oportunidades | `/oportunidades`: pesquisa, sector, província, estado de preparação/maturidade e dimensão por montante mínimo/máximo e moeda. Valores em moedas diferentes nunca são somados ou comparados automaticamente. Só aparecem oportunidades publicadas. | +| Ficha de oportunidade | `/oportunidades/{slug}`: ficha e manifestação de interesse existentes. | +| Sectores prioritários | `/sectores` e `/sectores/{slug}`: descrição do cadastro, oportunidades publicadas e acesso ao percurso de preparação e apoio. | +| Como Investir | `/como-investir`: seis etapas de preparação, documentação, enquadramento, parceiros e acompanhamento; roteiro de orientação, sem inventar prazos ou requisitos legais obrigatórios. | +| Legislação e Incentivos | `/legislacao`: documentos públicos catalogados como legislação, pesquisáveis por título, categoria e tema, com referência e download. | +| Documentos e Formulários | `/documentos`: catálogo público, pesquisa, categorias, temas e acesso aos formulários digitais. | +| Apoio ao Investidor | `/apoio`: formulário, protocolo, contactos configuráveis e FAQ sobre utilização do portal. | +| Bolsa de Parcerias | `/parcerias`: propostas publicadas, filtros e pedido de contacto intermediado. Conta com email verificado pode propor; gestor autorizado publica ou retira. Contactos e identidade da conta não são expostos na listagem pública. | +| Simulador Fiscal | `/simulador-fiscal`: cálculo aritmético de cenários com base, taxa e redução introduzidas pelo utilizador. Não incorpora taxas legais, elegibilidade ou concessão automática de incentivos. | +| Formulários | `/formularios/investidor` e `/formularios/empresa`: recolha de dados de contacto, sector/assunto, empresa quando aplicável, mensagem e consentimento; gera protocolo. Não equivale a constituição ou registo legal de empresa. | +| Balcão Virtual | `/balcao-virtual`: autenticação e email verificado; mostra pedidos enviados com a conta e estado das suas propostas. Liga ao painel existente da carteira. | + +O layout partilhado acrescenta breadcrumbs e o botão fixo **Contactar Apoio ao Investidor** às páginas Inertia, incluindo autenticação e gestão. Páginas de erro produzidas directamente pelo servidor não passam por este layout. + +## Gestão e dados + +`/management/portal-services` permite carregar PDF/TXT autorizado (até 10 MiB), catalogar, publicar e retirar documentos, e rever/publicar/retirar propostas de parceria. Usa o privilégio existente `manage-portal`, sem criar novos gestores e sem exigir uma segunda pessoa para publicar. + +Os ficheiros ficam no storage privado e os metadados no cadastro `documents`. Downloads só funcionam para documentos activos, classificados como públicos e catalogados como publicados; verificam caminho real e SHA-256. Reclassificar, eliminar, retirar ou alterar o ficheiro bloqueia o download. Não foi instalado um motor antivírus. + +A migration aditiva `2026_09_27_120000_public_portal_services` cria `portal_resources` e `portal_partnerships`; rollback com dados é recusado. Nenhum documento legal, contacto institucional ou anúncio fictício foi inserido. Sem conteúdo publicado, as páginas apresentam estado vazio e caminho para pedir apoio. + +Os pedidos reutilizam `interest_requests`, o serviço transaccional `InterestSubmission` e a gestão `/management/interests`, com tipo e contexto em `metadata`. Repetir a mesma chave e payload não duplica pedido ou evento de integração; reutilizar a chave com dados/conta diferentes devolve 409. Consentimento é obrigatório. O Balcão filtra pelo ID da conta gravado no envio, nunca por coincidência de email. Pedidos anónimos geram protocolo, mas não são associados retroactivamente a contas. + +Os eventos da outbox permanecem pendentes conforme a integração existente. Não foram enviados emails nem activado SuiteCRM. Os estados dos pedidos são actualizados na gestão existente; o Balcão não oferece chat nem submissão automática a entidades públicas. + +## Contactos e conteúdo + +Configurar `PORTAL_SUPPORT_EMAIL`, `PORTAL_SUPPORT_PHONE`, `PORTAL_SUPPORT_ADDRESS` e `PORTAL_SUPPORT_HOURS` no ambiente e limpar cache de configuração quando necessário. Campos vazios não geram contactos fictícios. Publicar os documentos reais no catálogo para preencher Legislação, Documentos e os destaques essenciais da página inicial. + +## Validação + +Execução final: **124 testes / 1.284 verificações aprovadas**. PHPStan nos controllers alterados, Pint, TypeScript e build aprovados. + +Testes funcionais cobrem páginas públicas, acesso autenticado, privacidade dos pedidos, consentimento, idempotência, moderação de parcerias, ausência de identidade pública, classificação/checksum dos downloads, documentos essenciais e filtros monetários. Os testes HTTP desactivam SSR para não dependerem do servidor Vite de desenvolvimento; a protecção contra chamadas HTTP inesperadas da IA permanece activa. + +Edge headless: 11 páginas públicas/autenticação verificadas a 390×844, com breadcrumbs, botão de apoio visível e sem transbordo horizontal ou excepções JavaScript. Capturas de início e apoio inspeccionadas. Fluxos privados, submissões e downloads foram verificados nos testes PHP com dados sintéticos em base isolada, não em contas reais no navegador. + +Página inicial também verificada a 1440×1000. Cenário fiscal exercitado no navegador: base 1.000 × taxa 10% × (1 − redução 50%) = 50. Resultados em `public-portal-browser-validation.json`. + +Antes da migration local foi criado e restaurado o backup de 74 tabelas em `storage/app/private/portal-services-backup-20260927-090641/`. O backup completo anterior permanece em `C:/laragon/backups/portal-investidor-20260927-101224/` e representa o estado anterior a esta entrega. diff --git a/docs/investmoz/relationships.csv b/docs/investmoz/relationships.csv new file mode 100644 index 00000000..09e47ed7 --- /dev/null +++ b/docs/investmoz/relationships.csv @@ -0,0 +1,70 @@ +Origem,Campo FK,Destino,Cardinalidade,Obrigatório,ON UPDATE,ON DELETE,Tabela associativa/regra,Migration +passkeys,user_id,users.id,N:1,Sim,NO ACTION,CASCADE,passkeys_user_id_foreign,database/migrations/2024_01_01_000000_create_passkeys_table.php +opportunities,sector_id,sectors.id,N:1,Sim,CASCADE,RESTRICT,opportunities_sector_id_foreign,database/migrations/2026_01_01_000002_create_opportunities_table.php +interest_requests,opportunity_id,opportunities.id,N:1,Não,CASCADE,SET NULL,interest_requests_opportunity_id_foreign,database/migrations/2026_01_01_000003_create_interest_requests_table.php +interest_requests,reviewed_by,users.id,N:1,Não,NO ACTION,SET NULL,interest_requests_reviewed_by_foreign,database/migrations/2026_01_01_000003_create_interest_requests_table.php +memberships,user_id,users.id,N:1,Sim,CASCADE,CASCADE,imz_memberships_user_id_fk,database/migrations/2026_09_24_001000_investmoz_identity.php +memberships,organization_id,organizations.id,N:1,Sim,CASCADE,CASCADE,imz_memberships_organization_id_fk,database/migrations/2026_09_24_001000_investmoz_identity.php +consents,user_id,users.id,N:1,Não,CASCADE,SET NULL,imz_consents_user_id_fk,database/migrations/2026_09_24_001000_investmoz_identity.php +geo_locations,parent_id,geo_locations.id,N:1,Não,CASCADE,RESTRICT,imz_geo_locations_parent_id_fk,database/migrations/2026_09_24_003000_investmoz_geography.php +geo_geometries,geo_location_id,geo_locations.id,N:1,Sim,CASCADE,CASCADE,imz_geo_geometries_geo_location_id_fk,database/migrations/2026_09_24_003000_investmoz_geography.php +project_locations,project_id,projects.id,N:1,Sim,CASCADE,CASCADE,imz_project_locations_project_id_fk,database/migrations/2026_09_24_004000_investmoz_projects.php +project_locations,geo_location_id,geo_locations.id,N:1,Sim,CASCADE,RESTRICT,imz_project_locations_geo_location_id_fk,database/migrations/2026_09_24_004000_investmoz_projects.php +value_chains,cluster_id,economic_clusters.id,N:1,Sim,CASCADE,CASCADE,imz_value_chains_cluster_id_fk,database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chain_links,from_value_chain_id,value_chains.id,N:1,Sim,CASCADE,CASCADE,imz_value_chain_links_from_value_chain_id_fk,database/migrations/2026_09_24_005000_investmoz_clusters.php +value_chain_links,to_value_chain_id,value_chains.id,N:1,Sim,CASCADE,CASCADE,imz_value_chain_links_to_value_chain_id_fk,database/migrations/2026_09_24_005000_investmoz_clusters.php +cluster_projects,cluster_id,economic_clusters.id,N:1,Sim,CASCADE,CASCADE,imz_cluster_projects_cluster_id_fk,database/migrations/2026_09_24_005000_investmoz_clusters.php +cluster_projects,project_id,projects.id,N:1,Sim,CASCADE,CASCADE,imz_cluster_projects_project_id_fk,database/migrations/2026_09_24_005000_investmoz_clusters.php +anchor_project_links,anchor_project_id,projects.id,N:1,Sim,CASCADE,CASCADE,imz_anchor_project_links_anchor_project_id_fk,database/migrations/2026_09_24_005000_investmoz_clusters.php +anchor_project_links,cluster_id,economic_clusters.id,N:1,Sim,CASCADE,CASCADE,imz_anchor_project_links_cluster_id_fk,database/migrations/2026_09_24_005000_investmoz_clusters.php +complementary_project_links,anchor_project_id,projects.id,N:1,Sim,CASCADE,CASCADE,imz_complementary_project_links_anchor_project_id_fk,database/migrations/2026_09_24_005000_investmoz_clusters.php +complementary_project_links,complementary_project_id,projects.id,N:1,Sim,CASCADE,CASCADE,imz_complementary_project_links_complementary_project_id_fk,database/migrations/2026_09_24_005000_investmoz_clusters.php +corridor_locations,corridor_id,development_corridors.id,N:1,Sim,CASCADE,CASCADE,imz_corridor_locations_corridor_id_fk,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_locations,geo_location_id,geo_locations.id,N:1,Sim,CASCADE,RESTRICT,imz_corridor_locations_geo_location_id_fk,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_projects,corridor_id,development_corridors.id,N:1,Sim,CASCADE,CASCADE,imz_corridor_projects_corridor_id_fk,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_projects,project_id,projects.id,N:1,Sim,CASCADE,CASCADE,imz_corridor_projects_project_id_fk,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_clusters,corridor_id,development_corridors.id,N:1,Sim,CASCADE,CASCADE,imz_corridor_clusters_corridor_id_fk,database/migrations/2026_09_24_006000_investmoz_corridors.php +corridor_clusters,cluster_id,economic_clusters.id,N:1,Sim,CASCADE,CASCADE,imz_corridor_clusters_cluster_id_fk,database/migrations/2026_09_24_006000_investmoz_corridors.php +investors,organization_id,organizations.id,N:1,Não,CASCADE,SET NULL,imz_investors_organization_id_fk,database/migrations/2026_09_24_007000_investmoz_crm.php +investor_contacts,investor_id,investors.id,N:1,Sim,CASCADE,CASCADE,imz_investor_contacts_investor_id_fk,database/migrations/2026_09_24_007000_investmoz_crm.php +investor_contacts,contact_id,contacts.id,N:1,Sim,CASCADE,CASCADE,imz_investor_contacts_contact_id_fk,database/migrations/2026_09_24_007000_investmoz_crm.php +leads,investor_id,investors.id,N:1,Não,CASCADE,SET NULL,imz_leads_investor_id_fk,database/migrations/2026_09_24_007000_investmoz_crm.php +leads,owner_user_id,users.id,N:1,Não,CASCADE,SET NULL,imz_leads_owner_user_id_fk,database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,lead_id,leads.id,N:1,Não,CASCADE,SET NULL,imz_crm_opportunities_lead_id_fk,database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,project_id,projects.id,N:1,Não,CASCADE,SET NULL,imz_crm_opportunities_project_id_fk,database/migrations/2026_09_24_007000_investmoz_crm.php +crm_opportunities,owner_user_id,users.id,N:1,Não,CASCADE,SET NULL,imz_crm_opportunities_owner_user_id_fk,database/migrations/2026_09_24_007000_investmoz_crm.php +activities,investor_id,investors.id,N:1,Não,CASCADE,CASCADE,imz_activities_investor_id_fk,database/migrations/2026_09_24_007000_investmoz_crm.php +activities,opportunity_id,crm_opportunities.id,N:1,Não,CASCADE,SET NULL,imz_activities_opportunity_id_fk,database/migrations/2026_09_24_007000_investmoz_crm.php +activities,actor_user_id,users.id,N:1,Não,CASCADE,SET NULL,imz_activities_actor_user_id_fk,database/migrations/2026_09_24_007000_investmoz_crm.php +tasks,assignee_user_id,users.id,N:1,Não,CASCADE,SET NULL,imz_tasks_assignee_user_id_fk,database/migrations/2026_09_24_007000_investmoz_crm.php +tasks,opportunity_id,crm_opportunities.id,N:1,Não,CASCADE,CASCADE,imz_tasks_opportunity_id_fk,database/migrations/2026_09_24_007000_investmoz_crm.php +investor_journeys,investor_id,investors.id,N:1,Sim,CASCADE,CASCADE,imz_investor_journeys_investor_id_fk,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +investor_journeys,opportunity_id,crm_opportunities.id,N:1,Não,CASCADE,SET NULL,imz_investor_journeys_opportunity_id_fk,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +journey_steps,journey_id,investor_journeys.id,N:1,Sim,CASCADE,CASCADE,imz_journey_steps_journey_id_fk,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +submissions,journey_step_id,journey_steps.id,N:1,Sim,CASCADE,CASCADE,imz_submissions_journey_step_id_fk,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +submissions,submitted_by,users.id,N:1,Não,CASCADE,SET NULL,imz_submissions_submitted_by_fk,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +aftercare_plans,investor_id,investors.id,N:1,Sim,CASCADE,CASCADE,imz_aftercare_plans_investor_id_fk,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +aftercare_plans,project_id,projects.id,N:1,Não,CASCADE,SET NULL,imz_aftercare_plans_project_id_fk,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +aftercare_plans,owner_user_id,users.id,N:1,Não,CASCADE,SET NULL,imz_aftercare_plans_owner_user_id_fk,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +constraints,aftercare_plan_id,aftercare_plans.id,N:1,Sim,CASCADE,CASCADE,imz_constraints_aftercare_plan_id_fk,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +escalations,constraint_id,constraints.id,N:1,Sim,CASCADE,CASCADE,imz_escalations_constraint_id_fk,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +escalations,escalated_by,users.id,N:1,Não,CASCADE,SET NULL,imz_escalations_escalated_by_fk,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +training_needs,project_id,projects.id,N:1,Não,CASCADE,SET NULL,imz_training_needs_project_id_fk,database/migrations/2026_09_24_009000_investmoz_education.php +training_needs,occupation_id,occupations.id,N:1,Não,CASCADE,RESTRICT,imz_training_needs_occupation_id_fk,database/migrations/2026_09_24_009000_investmoz_education.php +training_needs,skill_id,skills.id,N:1,Não,CASCADE,RESTRICT,imz_training_needs_skill_id_fk,database/migrations/2026_09_24_009000_investmoz_education.php +training_providers,organization_id,organizations.id,N:1,Não,CASCADE,SET NULL,imz_training_providers_organization_id_fk,database/migrations/2026_09_24_009000_investmoz_education.php +courses,provider_id,training_providers.id,N:1,Sim,CASCADE,CASCADE,imz_courses_provider_id_fk,database/migrations/2026_09_24_009000_investmoz_education.php +education_opportunities,course_id,courses.id,N:1,Sim,CASCADE,CASCADE,imz_education_opportunities_course_id_fk,database/migrations/2026_09_24_009000_investmoz_education.php +education_opportunities,geo_location_id,geo_locations.id,N:1,Não,CASCADE,SET NULL,imz_education_opportunities_geo_location_id_fk,database/migrations/2026_09_24_009000_investmoz_education.php +project_documents,project_id,projects.id,N:1,Sim,CASCADE,CASCADE,imz_project_documents_project_id_fk,database/migrations/2026_09_24_009500_investmoz_document_links.php +project_documents,document_id,documents.id,N:1,Sim,CASCADE,RESTRICT,imz_project_documents_document_id_fk,database/migrations/2026_09_24_009500_investmoz_document_links.php +investor_documents,investor_id,investors.id,N:1,Sim,CASCADE,CASCADE,imz_investor_documents_investor_id_fk,database/migrations/2026_09_24_009500_investmoz_document_links.php +investor_documents,document_id,documents.id,N:1,Sim,CASCADE,RESTRICT,imz_investor_documents_document_id_fk,database/migrations/2026_09_24_009500_investmoz_document_links.php +journey_documents,journey_id,investor_journeys.id,N:1,Sim,CASCADE,CASCADE,imz_journey_documents_journey_id_fk,database/migrations/2026_09_24_009500_investmoz_document_links.php +journey_documents,document_id,documents.id,N:1,Sim,CASCADE,RESTRICT,imz_journey_documents_document_id_fk,database/migrations/2026_09_24_009500_investmoz_document_links.php +aftercare_documents,aftercare_plan_id,aftercare_plans.id,N:1,Sim,CASCADE,CASCADE,imz_aftercare_documents_aftercare_plan_id_fk,database/migrations/2026_09_24_009500_investmoz_document_links.php +aftercare_documents,document_id,documents.id,N:1,Sim,CASCADE,RESTRICT,imz_aftercare_documents_document_id_fk,database/migrations/2026_09_24_009500_investmoz_document_links.php +education_documents,education_opportunity_id,education_opportunities.id,N:1,Sim,CASCADE,CASCADE,imz_education_documents_education_opportunity_id_fk,database/migrations/2026_09_24_009500_investmoz_document_links.php +education_documents,document_id,documents.id,N:1,Sim,CASCADE,RESTRICT,imz_education_documents_document_id_fk,database/migrations/2026_09_24_009500_investmoz_document_links.php +audit_events,actor_user_id,users.id,N:1,Não,RESTRICT,RESTRICT,imz_audit_events_actor_user_id_fk,database/migrations/2026_09_24_010000_investmoz_governance_ai.php +reconciliation_issues,sync_run_id,sync_runs.id,N:1,Sim,CASCADE,CASCADE,imz_reconciliation_issues_sync_run_id_fk,database/migrations/2026_09_24_011000_investmoz_integrations.php diff --git a/docs/investmoz/requirements-audit.csv b/docs/investmoz/requirements-audit.csv new file mode 100644 index 00000000..bb722932 --- /dev/null +++ b/docs/investmoz/requirements-audit.csv @@ -0,0 +1,159 @@ +"ID","Familia","Paragrafo","Requisito","Criterio","Estado","Evidencia" +"RF-AGR-001","AGR","3089","Classificar a oportunidade agrícola ou fundiária por natureza: exploração agrícola, agro-indústria, pecuária, silvicultura, irrigação, armazenagem, participação empresarial, activos e benfeitorias, parceria ou financiamento.","A submissão exige pelo menos uma natureza; a opção seleccionada activa os campos, documentos e fluxo correspondentes; designações não admissíveis ficam bloqueadas.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-AGR-002","AGR","3093","Registar localização e extensão da área.","Província, distrito, posto, localidade, área em hectares e localização principal são obrigatórios; coordenadas ou polígono são validados e só publicados após autorização.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-AGR-003","AGR","3097","Registar situação de uso e aproveitamento da terra.","O proponente informa referência, titular, finalidade, área, prazo, estado, entidade emissora, ónus e documento de suporte; divergência impede publicação.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-AGR-004","AGR","3101","Descrever a exploração e a cadeia de valor.","O registo contém culturas ou espécies, sistema produtivo, calendário, capacidade, produtividade histórica ou prevista, processamento, armazenagem, logística e mercados; estimativas são identificadas como tal.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-AGR-005","AGR","3105","Registar disponibilidade e direito de uso da água.","Fonte, volume, sazonalidade, infra-estrutura, autorização e entidade competente são exigidos quando o projecto depende de irrigação; falta de licença aplicável gera pendência.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-AGR-006","AGR","3109","Inventariar activos e benfeitorias abrangidos.","Máquinas, edifícios, sistemas de irrigação, armazéns, plantações permanentes, animais e outros activos possuem descrição, quantidade, estado, titularidade e valor indicativo separado do direito fundiário.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-AGR-007","AGR","3113","Registar situação ambiental e social.","Categoria, licença ou parecer, plano de gestão, comunidades abrangidas, reassentamento quando aplicável, reclamações e obrigações são declarados; risco elevado exige validação jurídica/ambiental.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-AGR-008","AGR","3117","Validar legitimidade e disponibilidade para a operação.","Identidade, poderes, titularidade dos activos, autorizações, ónus, litígios e consentimentos são verificados antes da correspondência ou diligência.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-AGR-009","AGR","3121","Permitir pesquisa e correspondência agrícola.","O utilizador filtra por cultura, cadeia, área, localização, irrigação, fase, natureza da operação, valor e certificação; o matching explica critérios e exige revisão humana.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-AGR-010","AGR","3125","Gerir diligência sectorial e validade da informação.","Checklist controla documentos fundiários, fiscais, laborais, ambientais, hídricos, sanitários e operacionais; documentos expirados suspendem divulgação ou avançam para revalidação.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CLU-001","CLU","509","Administrar clusters, subclusters, cadeias de valor, taxonomias e versões.","Cada cluster possui código, visão, âmbito territorial, sectores, elos, estado, responsável, fonte, validade e histórico; alterações são aprovadas e auditadas.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CLU-002","CLU","513","Relacionar o cluster com projectos, oportunidades, actores e territórios.","As relações com projectos, negócios, empresas âncora, PME, fornecedores, compradores, financiadores, reguladores e instituições indicam papel, prioridade, período e estado, sem duplicar os registos de origem e com navegação nos dois sentidos.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CLU-003","CLU","517","Representar a cadeia de valor e identificar lacunas de investimento.","O cluster apresenta fornecimento, produção, transformação, logística, comercialização, serviços e mercados; cada lacuna possui localização, capacidade necessária, justificação, estado e chamada para acção.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CLU-004","CLU","521","Disponibilizar inteligência territorial e comparação multicritério.","Mapas autorizados mostram projectos, recursos, infra-estruturas, actores e riscos por unidade territorial; comparações usam critérios, período, fonte e limitações uniformes e não constituem garantia.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CLU-005","CLU","525","Gerir necessidades de financiamento e correspondência com financiadores.","A necessidade contém elo, fase, instrumento, valor, moeda, beneficiário, finalidade e elegibilidade; o CRM regista correspondências explicáveis e sujeitas a revisão humana.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CLU-006","CLU","529","Gerir programas, iniciativas, constrangimentos e planos de acção.","Cada registo possui objectivo ou problema, território, responsável, parceiros, orçamento, prazo, indicador, dependências, risco, escalonamento, estado e decisão necessária.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CLU-007","CLU","533","Publicar páginas e dossiers de cluster a partir de dados validados.","A página reúne visão, cadeia, mapa, projectos, lacunas, actores, financiamento, reguladores, riscos, indicadores e chamada para acção, preservando fontes, versões e níveis de divulgação.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CLU-008","CLU","537","Captar, qualificar e acompanhar manifestações de interesse por cluster.","O investidor informa cluster, elo, território, papel, capacidade, instrumento e horizonte; o CRM cria o lead com origem, responsável, pontuação, estado e próxima acção.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CLU-009","CLU","541","Gerir campanhas, missões, eventos e conteúdos por cluster.","Cada campanha relaciona público, mercado-alvo, mensagem, activos, orçamento, leads, reuniões, propostas, atribuição e valor do pipeline.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CLU-010","CLU","545","Medir prontidão, desempenho, qualidade dos dados e impacto.","Os painéis distinguem dados confirmados, estimados e pendentes e apresentam fórmula, meta, tendência, fonte, responsável, data de actualização e registos de origem.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CLU-011","CLU","549","Controlar governação, decisões, aprovações e conflitos de interesse.","Reuniões, decisões, documentos, versões, participantes, impedimentos e aprovações ficam ligados ao cluster; ninguém valida isoladamente a informação que submeteu.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CLU-012","CLU","553","Disponibilizar APIs, eventos e exportação controlada dos dados de cluster.","As integrações aplicam autenticação, escopos, confidencialidade, versionamento, idempotência, auditoria e autorização da entidade proprietária.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-001","COR","819","Administrar corredores, áreas de influência, nós territoriais, versões e estados.","Cada corredor possui código, visão, coordenador, territórios, geometria, nós, fontes, validade, estado e histórico aprovado.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-002","COR","823","Relacionar corredores com projectos âncora.","Cada projecto âncora indica função, capacidade, território, dependências, clusters beneficiados e fase; um projecto pode servir vários corredores com relação justificada.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-003","COR","827","Relacionar corredores com clusters e cadeias de valor.","A relação identifica elos suportados, mercados, recursos, infra-estruturas, lacunas, actores e prioridade, sem duplicar o cadastro do cluster.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-004","COR","831","Gerir projectos complementares públicos e privados.","O sistema permite propor, validar, publicar e acompanhar novos projectos ligados ao corredor, projecto âncora, cluster, elo e território.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-005","COR","835","Mapear infra-estruturas, capacidade e constrangimentos.","Energia, água, transportes, telecomunicações, armazenagem, fronteiras e serviços apresentam localização, capacidade, utilização, fonte e estado.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-006","COR","839","Gerir dependências e sequência de investimentos.","O corredor identifica predecessores, bloqueios, caminho crítico, marcos, responsáveis e data necessária; alteração material recalcula impactos.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-007","COR","843","Publicar página e dossier do corredor.","A página apresenta mapa autorizado, projectos âncora, clusters, oportunidades, infra-estruturas, constrangimentos, investimentos complementares, indicadores e chamada para acção.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-008","COR","847","Captar interesse e propostas por corredor.","O investidor selecciona corredor, cluster, projecto, papel, instrumento, capacidade e horizonte; o CRM cria lead, responsável e próxima acção.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-009","COR","855","Comparar corredores e cenários territoriais.","A comparação aplica critérios, pesos, fontes e período uniformes e distingue capacidade instalada, planeada e condicionada.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-010","COR","859","Gerir planos de acção, programas e decisões.","Cada acção possui objectivo, responsável, parceiros, orçamento, prazo, indicador, dependências, risco, estado e decisão necessária.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-011","COR","863","Monitorar execução e impacto do corredor.","O painel mostra projectos, investimentos, progresso, emprego, exportações, conteúdo local, capacidade, constrangimentos e qualidade dos dados.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-012","COR","867","Gerir governação e coordenação interinstitucional.","Reuniões, decisões, compromissos, prazos, conflitos e escalonamentos ficam registados; ninguém valida isoladamente a informação que submeteu.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-013","COR","871","Integrar corredores com clusters, CRM, GIS, financiamento e campanhas.","Os módulos partilham IDs, estados, fontes e histórico; a integração é idempotente e não cria relações duplicadas.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-014","COR","875","Gerir riscos territoriais, ambientais, sociais, regulatórios, financeiros e de mercado.","Cada risco possui probabilidade, impacto, exposição, proprietário, mitigação, contingência, prazo e evidência actualizada.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-015","COR","879","Disponibilizar APIs e eventos dos corredores.","Integrações aplicam autenticação, escopos, confidencialidade, versionamento, idempotência, auditoria e autorização da fonte.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-COR-016","COR","883","Aplicar inteligência artificial supervisionada ao corredor.","A IA usa dados autorizados, apresenta fonte, explicação e confiança, não altera decisões nem publica recomendações sem revisão humana e mantém auditoria.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CRM-001","CRM","4341","Registo único de contacto e organização. Criar ou actualizar contactos e organizações provenientes do portal, campanhas, eventos, importações e registo manual. Impedir duplicação por correio electrónico normalizado, telefone, domínio, NUIT ou identificação equivalente. A fusão deve preservar origem, actividades, consentimentos e relações.","1) Ao submeter um correio já existente, o sistema actualiza ou propõe fusão, sem criar duplicado silencioso. 2) A pesquisa encontra o registo por nome, correio, telefone, NUIT ou domínio. 3) A fusão mostra campos divergentes, exige escolha do valor principal e mantém histórico. 4) Uma organização admite vários contactos e um contacto pode representar mais de uma organização com período e função.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CRM-002","CRM","4345","Origem e atribuição multicanal. Registar primeira origem, última origem, canal, campanha, conteúdo, evento, parceiro, parâmetros de campanha e formulário. A primeira origem é imutável; a última origem actualiza-se a cada nova interacção identificada.","1) Todo lead possui primeira origem e data. 2) Nova conversão actualiza a última origem sem apagar a primeira. 3) Registos importados exigem fonte e responsável. 4) Relatórios distinguem origem inicial, assistida e final. 5) Alteração manual exige permissão e motivo.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CRM-003","CRM","4349","Perfil do investidor. Classificar perfil principal e secundário, mandato, ticket, instrumentos, sectores, territórios, horizonte, nível de envolvimento, experiência, impacto esperado e restrições. A classificação pode ser sugerida pelo sistema, mas depende de confirmação humana.","1) O perfil pode ser criado progressivamente. 2) Sector, localização, instrumento e faixa financeira admitem múltiplas selecções priorizadas. 3) O gestor confirma ou corrige a sugestão e regista fundamento. 4) Alterações relevantes recalculam correspondências. 5) Campos altamente restritos obedecem a permissões.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CRM-004","CRM","4353","Pontuação configurável e explicável. Calcular adequação, capacidade, experiência, intenção, compatibilidade do instrumento, conformidade e impacto. Pesos, faixas e condições eliminatórias são versionados por campanha ou carteira. A decisão final é humana.","1) O resultado apresenta pontuação total, notas por critério, evidência, versão e data. 2) Critério sem evidência fica pendente ou recebe regra definida. 3) Condição eliminatória bloqueia avanço e gera revisão. 4) Gestor pode ajustar com justificativa e autorização. 5) Reprocessamento não altera o histórico anterior.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CRM-005","CRM","4357","Funil, transições e etapas obrigatórias. Gerir estados desde visitante identificado até ganho, perdido ou suspenso. Cada transição define campos, actividades, aprovação, motivo e notificação obrigatórios. Retrocesso mantém histórico e explica a causa.","1) Não é possível avançar sem cumprir critérios da etapa. 2) Estados encerrados exigem motivo padronizado. 3) Retrocesso exige comentário. 4) Cada transição regista autor, data, estado anterior e novo. 5) Probabilidade e previsão actualizam segundo regra configurada, sujeita a validação do gestor.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CRM-006","CRM","4361","Responsável, equipa e próxima acção. Todo registo activo deve possuir responsável principal, equipa, fila e próxima acção com prazo. A atribuição considera sector, localização, idioma, campanha, promotor e carga de trabalho.","1) Lead activo sem responsável ou próxima acção aparece em alerta. 2) Redistribuição conserva histórico. 3) Ausência programada encaminha tarefas. 4) O gestor visualiza carteira por responsável e atraso. 5) Apenas utilizador autorizado encerra ou transfere a oportunidade.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CRM-007","CRM","4369","Cadências e níveis de serviço. Configurar sequências por perfil, etapa, campanha e idioma, com modelos, canais, intervalos, tentativas, horário, pausa e escalonamento. A comunicação depende de consentimento e preferência.","1) Evento elegível inicia a cadência correcta. 2) Resposta, reunião, revogação ou mudança de estado pausa a sequência. 3) Falha gera nova tentativa dentro do limite e tarefa manual ao esgotar. 4) Atraso no nível de serviço alerta responsável e superior. 5) Todos os envios guardam modelo, versão, canal e resultado.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CRM-008","CRM","4373","Relações entre investidor, projecto, local, campanha e promotor. Um investidor pode ter vários interesses e oportunidades; uma oportunidade pode envolver vários contactos, organizações, projectos, locais, promotores e parceiros, indicando papel e período.","1) O sistema permite múltiplas relações sem duplicar o contacto. 2) Cada relação possui tipo, prioridade, data e estado. 3) A remoção lógica não apaga histórico. 4) Pesquisa e relatórios navegam em ambos os sentidos. 5) Acesso a relações confidenciais respeita permissões.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CRM-009","CRM","4377","Valor, probabilidade e previsão. Registar moeda, valor mínimo, máximo e estimado, instrumento, probabilidade, data prevista, cenário e fundamento. Valores indicativos não são apresentados como investimento confirmado.","1) Moeda e data de referência são obrigatórias quando há valor. 2) Mínimo não excede máximo. 3) Probabilidade automática pode ser ajustada com fundamento. 4) Alterações ficam historizadas. 5) O painel separa valor bruto, ponderado, proposto e confirmado.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CRM-010","CRM","4381","Painéis e relatórios. Apresentar aquisição, conversão, velocidade, pipeline, investimento confirmado, produtividade, qualidade dos dados, campanhas e motivos de perda. Filtros devem respeitar acesso e período.","1) Filtros incluem campanha, perfil, sector, país, local, projecto, promotor, gestor e etapa. 2) Totais reconciliam com registos fonte. 3) Exportação respeita mascaramento. 4) Indicadores exibem definição e data de actualização. 5) Utilizador pode abrir a lista que compõe cada indicador.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-CRM-011","CRM","4385","Protecção de dados, consentimentos e confidencialidade. Registar finalidade, base, versão, canal, data, preferências e revogação. Aplicar acesso por função, minimização, retenção, mascaramento e bloqueio de partilha reservada.","1) Comunicação não obrigatória cessa após revogação. 2) Acesso e exportação de dados restritos ficam auditados. 3) Partilha com promotor exige consentimento e aprovação vigentes. 4) Retenção vencida cria tarefa de eliminação ou anonimização. 5) Pedido de acesso ou correcção recebe protocolo e prazo.","Parcial","Autenticação/Fortify ou formulário contextual/consentimento básico; critérios integrais não cobertos" +"RF-CRM-012","CRM","4389","Encerramento e pós-investimento. Ganho exige confirmação pela entidade competente, valor autorizado, instrumento, data, projecto, localização e plano de acompanhamento. Perda exige motivo, concorrente ou alternativa quando aplicável e possibilidade de reactivação.","1) Apenas utilizador autorizado marca “ganho”. 2) Valor confirmado fica separado do indicativo. 3) Encerramento cria avaliação e plano pós-investimento. 4) Perda usa motivo de catálogo e comentário. 5) Reactivação cria nova fase sem apagar o encerramento anterior.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-001","EDU","1785","Criar competência com código, nível, sector, fonte e versão.","Tentar duplicar por sinónimo ou alterar versão activa sem aprovação.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-002","EDU","1789","Registar necessidade ligada a território, corredor, cluster, âncora, ocupação, quantidade e prazo.","Submeter sem fonte, local, responsável ou distinguir estimativa de compromisso.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-003","EDU","1793","Cadastrar instituição e curso com capacidade, localização e acreditação.","Publicar curso expirado, não verificado ou sem identificar ausência de acreditação.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-004","EDU","1797","Comparar procura e oferta no mesmo território, período, nível e competência.","Comparar unidades, períodos ou níveis incompatíveis sem conversão explícita.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-005","EDU","1801","Converter lacuna validada em oportunidade educativa completa.","Publicar sem promotor, localização, beneficiários, modelo financeiro, riscos ou autorização.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-006","EDU","1805","Navegar da oportunidade para corredor, âncora, cluster, elo e competências e regressar.","Eliminar relação usada por oportunidade activa ou criar relação duplicada.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-007","EDU","1809","Registar acreditação válida com entidade, referência e validade.","Aceder a documento restrito sem permissão ou manter oferta como válida após expiração.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-008","EDU","1813","Submeter interesse de investidor, operador, financiador ou fornecedor.","Submeter sem consentimento, contacto verificável ou papel definido.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-009","EDU","1821","Empregador regista procura com ocupação, quantidade, local, prazo e possibilidade de estágio.","Alterar procura confirmada sem nova versão ou publicar informação confidencial.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-010","EDU","1825","Instituição apresenta proposta ligada a necessidade validada.","Submeter currículo, capacidade ou certificação incompatível com a necessidade.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-011","EDU","1829","Gerar correspondência com critérios, evidências e confiança.","Encaminhar automaticamente apesar de condição eliminatória ou sem revisão humana.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-012","EDU","1833","Registar vagas, inscritos, concluintes, certificados, estágios e colocação.","Informar colocação sem período, elegibilidade, empregador ou evidência.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-013","EDU","1837","Criar campanha ou bolsa ligada a área, território, curso e financiador.","Ultrapassar orçamento ou comunicar elegibilidade não aprovada.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-014","EDU","1841","IA prevê lacuna e mostra horizonte, dados, pressupostos, confiança e limites.","Apresentar recomendação como certificação, admissão ou emprego garantido.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-015","EDU","1845","Filtrar painel por corredor, cluster, projecto, competência e período.","Exportar dados pessoais ou restritos sem escopo.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EDU-016","EDU","1849","Repetir pedido de integração com a mesma chave de idempotência.","Usar token sem escopo, versão incompatível ou payload inválido.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EII-001","EII","1082","Disponibilizar conta única e identidade digital progressiva.","O utilizador cria uma conta, confirma contactos, activa autenticação multifactor quando exigido e recebe nível de garantia compatível com os documentos e verificações concluídos.","Parcial","Autenticação/Fortify ou formulário contextual/consentimento básico; critérios integrais não cobertos" +"RF-EII-002","EII","1086","Gerir perfil único de pessoa, organização e representação.","Uma pessoa pode representar várias organizações mediante poderes válidos; alterações de identidade, função ou mandato ficam versionadas e afectam imediatamente as permissões.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EII-003","EII","1090","Reutilizar dados e documentos com consentimento e finalidade definidos.","O investidor escolhe os dados reutilizáveis; o sistema mostra origem, finalidade, destinatário, validade e versão e impede reutilização além da autorização concedida.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EII-004","EII","1094","Orquestrar uma jornada única desde a descoberta ao pós-investimento.","Todos os passos, estados, responsáveis, tarefas, documentos, decisões e interacções conservam um identificador de jornada e ligação ao CRM, projecto, cluster, corredor e organização aplicáveis.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EII-005","EII","1098","Disponibilizar painel único do investidor.","O painel reúne perfil, oportunidades guardadas, manifestações, processos, tarefas, reuniões, documentos, notificações, constrangimentos, investimentos e recomendações, respeitando as permissões.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EII-006","EII","1102","Disponibilizar caixa única de tarefas, mensagens e notificações.","Cada item apresenta prioridade, prazo, responsável, origem, estado, acção necessária e histórico; respostas e falhas de entrega ficam auditadas.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EII-007","EII","1106","Integrar favoritos, comparação, dossiers e manifestação de interesse.","O investidor guarda e compara oportunidades, solicita dossier e inicia formulário pré-preenchido sem perder o contexto de sector, território, cluster, corredor ou campanha.","Parcial","Autenticação/Fortify ou formulário contextual/consentimento básico; critérios integrais não cobertos" +"RF-EII-008","EII","1110","Disponibilizar cofre documental do investidor.","Documentos possuem categoria, proprietário, versão, validade, classificação, permissões, registo de acesso e reutilização controlada; ficheiros são verificados antes de uso.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EII-009","EII","1118","Integrar o balcão único e serviços institucionais.","Pedidos externos mostram entidade, protocolo, dependências, estado, prazo, custo validado e última sincronização; falhas de integração geram aviso e tarefa manual.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EII-010","EII","1122","Gerir transacção assistida e sala segura.","Correspondências, autorizações, confidencialidade, diligência, reuniões e documentos reservados seguem perfis, segregação de funções e trilha de auditoria.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EII-011","EII","1126","Iniciar e manter o acompanhamento pós-investimento.","A confirmação da instalação ou do compromisso cria um plano pós-investimento ligado ao registo original, com investimento, contactos, obrigações, marcos, indicadores e responsável.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EII-012","EII","1130","Gerir constrangimentos, pedidos de apoio e escalonamentos.","Cada caso possui categoria, prioridade, impacto, entidade, responsável, SLA, evidências, decisão, solução e satisfação; casos vencidos são escalonados.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EII-013","EII","1134","Identificar reinvestimento, expansão e fornecedores locais.","O sistema relaciona necessidades do investidor com oportunidades, financiamento, fornecedores e competências e exige revisão humana antes do encaminhamento.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EII-014","EII","1138","Personalizar conteúdos e recomendações com IA supervisionada.","A recomendação mostra critérios, fontes, confiança e limitações, permite contestação e nunca toma decisão oficial ou autoriza contacto sem validação aplicável.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EII-015","EII","1142","Garantir continuidade entre canais e idiomas.","O contexto é preservado entre portal, correio electrónico, helpdesk e atendimento humano; conteúdos e estados críticos permanecem coerentes em português e inglês.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-EII-016","EII","1146","Disponibilizar APIs, eventos e exportação controlada da jornada.","Integrações usam IDs comuns, autenticação, escopos, consentimento, versionamento, idempotência, auditoria e retorno de erros sem expor dados protegidos.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-GEO-001","GEO","3705","Manter cadastro único de locais de investimento.","Cada local possui identificador, hierarquia territorial, nome oficial, fonte, estado e histórico.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-GEO-002","GEO","3708","Permitir vários projectos públicos ou privados no mesmo local.","Um local pode relacionar múltiplos projectos sem duplicação; cada relação indica natureza, papel e período.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-GEO-003","GEO","3711","Permitir várias localizações por projecto.","O projecto define uma localização principal e zero ou mais locais complementares, troços ou áreas.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-GEO-004","GEO","3714","Capturar coordenadas e geometrias.","O sistema aceita ponto, linha e polígono, valida formato, sistema de referência, extensão territorial e ficheiro.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-GEO-005","GEO","3717","Apresentar mapa apenas quando autorizado.","O mapa público só é renderizado se a localização estiver validada, a autorização vigente e o nível de detalhe permitido.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-GEO-006","GEO","3720","Aplicar níveis de precisão e confidencialidade.","O sistema pode publicar província, distrito, ponto aproximado, ponto exacto, traçado ou polígono sem expor dados mais restritos.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-GEO-007","GEO","3723","Gerir pedidos de validação institucional.","Pedido possui protocolo, entidade, ponto focal, prazo, anexos, comentários, decisão e validade.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-GEO-008","GEO","3726","Suspender mapas desactualizados ou revogados.","Nova versão, expiração ou revogação remove o mapa público e cria tarefa de revalidação.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-GEO-009","GEO","3729","Pesquisar projectos por mapa e localização.","O utilizador filtra por província, distrito, raio, corredor, ZEE, parque, bacia, sector e natureza pública ou privada.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-GEO-010","GEO","3732","Importar e exportar dados geográficos autorizados.","O sistema suporta formatos geográficos definidos, gera relatório de erros e preserva fonte, versão e permissões.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MIN-001","MIN","3260","Aplicar formulário e checklist específicos por fase mineira.","Campos e documentos mudam conforme fase e modalidade sem eliminar dados históricos.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MIN-002","MIN","3263","Validar título, titular, área e validade.","Inconsistência entre título, titular ou polígono impede aprovação e gera pendência auditável.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MIN-003","MIN","3266","Controlar declarações de recursos e reservas.","Publicação exige categoria, data, padrão, fonte e responsável técnico; alegação sem suporte é bloqueada.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MIN-004","MIN","3269","Gerir documentos ambientais, sociais e de encerramento.","Validade e obrigações são monitoradas; expiração ou passivo crítico suspende o registo.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MIN-005","MIN","3272","Controlar cessão, mudança de controlo e aprovações.","A oportunidade mostra estado condicionado até à aprovação competente; conclusão exige evidência formal.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MIN-006","MIN","3275","Pesquisar e corresponder oportunidades mineiras.","Filtros incluem recurso, fase, título, localização, valor, modalidade e infra-estrutura; recomendação é explicável e revista por gestor.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-001","MN","2957","Registar utilizadores singulares e colectivos com verificação de contactos.","Conta só é activada após confirmação do correio electrónico ou telefone; tentativas e falhas ficam registadas.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-002","MN","2961","Gerir perfis, representantes, funções e organizações.","Uma organização pode ter vários utilizadores; cada acção identifica o utilizador e a função.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-003","MN","2965","Criar, guardar, editar e submeter ofertas e procuras.","Rascunho persiste; submissão bloqueia campos essenciais e gera número de protocolo.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-004","MN","2969","Aplicar campos condicionais por tipo de pessoa, modalidade e fase.","Campos aparecem e tornam-se obrigatórios conforme regras da matriz, sem perda de dados válidos.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-005","MN","2973","Validar formatos, intervalos, dependências, duplicados e consentimentos.","O sistema impede submissão inválida e apresenta mensagem associada ao campo e orientação de correcção.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-006","MN","2977","Carregar e gerir documentos por categoria e versão.","Ficheiros permitidos passam por antivírus, recebem metadados, validade e controlo de acesso.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-007","MN","2981","Configurar visibilidade por registo e por campo.","Utilizador não autorizado nunca recebe o valor protegido na interface, exportação, pesquisa ou API.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-008","MN","2985","Executar validação e aprovação por perfis.","Transições respeitam a matriz de estados, segregação de funções e aprovações obrigatórias.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-009","MN","2989","Publicar, suspender, renovar, expirar e retirar oportunidades.","Apenas registos aprovados são publicados; todas as alterações mantêm histórico e motivo.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-010","MN","2993","Pesquisar e filtrar oportunidades.","Pesquisa combina modalidade, sector, localização, valor, dimensão, fase e data; não revela campos restritos.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-011","MN","2997","Guardar pesquisas e oportunidades favoritas.","Utilizador pode criar, editar e eliminar preferências e receber alertas autorizados.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-012","MN","3001","Calcular correspondência entre oferta e procura.","Condições eliminatórias são avaliadas primeiro; pontuação guarda pesos, critérios, versão e explicação.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-013","MN","3005","Permitir revisão humana da correspondência.","Nenhum convite externo é enviado sem aprovação do gestor; decisão e fundamento ficam auditáveis.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-014","MN","3009","Registar manifestação de interesse e condições preliminares.","Interessado submete mensagem estruturada e documentos exigidos; recebe protocolo.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-015","MN","3013","Obter autorização bilateral antes da revelação de identidade.","Contactos permanecem ocultos até às duas autorizações válidas e ao acordo de confidencialidade quando exigido.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-016","MN","3017","Gerar, assinar e versionar acordo de confidencialidade.","O sistema associa o acordo às partes e oportunidade, regista assinatura e impede acesso antecipado.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-017","MN","3021","Disponibilizar sala segura de informação.","Proprietário concede acesso por pasta/documento e prazo; visualizações, tentativas e downloads ficam registados.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-018","MN","3025","Gerir perguntas, reuniões, tarefas e marcos de diligência.","Cada item tem responsável, prazo, estado, comentários, anexos e lembretes.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-019","MN","3029","Enviar notificações transaccionais multicanal.","Evento gera modelo correcto; envio respeita idioma e preferência e regista entrega, falha e nova tentativa.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-020","MN","3033","Integrar oportunidades, contactos e actividades no CRM.","Não são criados duplicados; estado, responsável e histórico mantêm sincronização controlada.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-021","MN","3037","Registar consentimentos e pedidos de exercício de direitos.","Versão, finalidade, data e origem são conservadas; revogação bloqueia tratamento não obrigatório.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-022","MN","3041","Aplicar retenção, anonimização e eliminação.","Rotina identifica registos vencidos, exige aprovação quando aplicável e produz comprovativo de execução.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-023","MN","3045","Manter trilho de auditoria imutável.","Criação, consulta sensível, alteração, aprovação, exportação e partilha registam autor, data, objecto e resultado.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-024","MN","3049","Gerir denúncias, reclamações, conflitos e recursos.","Caso recebe protocolo, prioridade, responsável, prazo, decisão e comunicação ao requerente.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-025","MN","3053","Produzir painéis e relatórios operacionais.","Indicadores filtram período, sector, localização, modalidade, estado, conversão e tempo de tratamento; exportação respeita permissões.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-026","MN","3057","Administrar catálogos, regras, pesos, prazos e modelos.","Alterações são versionadas, aprovadas e aplicadas apenas a partir da data definida.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-027","MN","3061","Expor serviços de integração por API segura.","API usa autenticação, autorização, limites, logs, idempotência e tratamento normalizado de erros.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MN-028","MN","3065","Permitir localização em português e inglês.","Rótulos, mensagens, modelos e conteúdos administráveis surgem no idioma seleccionado com fallback definido.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MON-001","MON","3319","Produto, serviço, plano, preço, imposto e validade são administrados por Comercial; Finanças aprova preço e fiscalidade; Jurídico aprova termos. Versões activas não podem ser alteradas retroactivamente.","Entrada: código, descrição, unidade, moeda, preço, imposto, vigência, elegibilidade. Saída: item comercial versionado e disponível para proposta.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MON-002","MON","3323","A proposta relaciona cliente, oportunidade, projecto ou campanha. Descontos acima do limite exigem aprovação. Aceitação cria contrato e calendário facturável.","Entrada: cliente, itens, quantidades, preço, desconto, imposto, validade, termos e anexos. Saída: proposta/contrato numerado e obrigações.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MON-003","MON","3327","A regra de comissão define pagador, beneficiário, base elegível, taxa, escalão, limites, facto gerador, exclusões e vigência. Jurídico e Finanças aprovam.","Entrada: contrato, serviço, projecto, base, percentagem/fixo, moeda, datas e condições. Saída: regra activa e versionada.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MON-004","MON","3331","O cálculo usa a regra vigente na data do facto gerador e conserva todos os componentes. Conversão cambial usa fonte e data aprovadas.","Entrada: valor-base, moeda, regra, taxa cambial, imposto, exclusões. Saída: memória de cálculo, comissão bruta, impostos e valor líquido.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MON-005","MON","3335","Gestor confirma o evento; Finanças valida cálculo; aprovador independente decide em conflito. O criador não pode aprovar.","Entrada: evidência, cálculo, contrato, comentários. Saída: comissão aprovada, rejeitada ou pendente.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MON-006","MON","3339","Factura é emitida apenas a partir de contrato ou comissão aprovada. Notas de crédito referenciam documento original. Numeração segue série autorizada.","Entrada: cliente fiscal, linhas, imposto, moeda, emissão, vencimento e referência. Saída: factura, nota de crédito ou documento de cobrança.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MON-007","MON","3347","Tesouraria regista ou importa recebimentos; sistema propõe correspondência; utilizador autorizado confirma a alocação.","Entrada: referência, pagador, conta, data-valor, montante, moeda e comprovativo. Saída: alocação, saldo, diferença e estado.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MON-008","MON","3351","Estorno, cancelamento, reembolso e disputa exigem motivo, evidência e aprovação. A comissão vinculada é recalculada, suspensa ou recuperada conforme contrato.","Entrada: transacção original, tipo de reversão, valor, motivo e anexos. Saída: nota de crédito, saldo revisto, comissão ajustada e caso de disputa.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MON-009","MON","3355","Finanças configura a regra de reconhecimento e de partilha por serviço, período, marco ou recebimento. Alterações são versionadas.","Entrada: contrato, calendário, obrigação, montante, centro de custo, projecto e beneficiário. Saída: plano de reconhecimento e lançamentos exportáveis.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MON-010","MON","3359","CRM origina oportunidade e contrato; facturação actualiza estados financeiros; contabilidade recebe documentos e pagamentos por API idempotente.","Entrada: oportunidade ganha, contrato, factura, pagamento e códigos contabilísticos. Saída: IDs cruzados, estados sincronizados e relatório de reconciliação.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MON-011","MON","3363","Separação mínima entre criação, aprovação, emissão, recebimento, conciliação e estorno. Perfis e limites são aprovados pela Direcção.","Entrada: utilizador, função, acção, valor e objecto. Saída: autorização ou bloqueio e evento de auditoria.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-MON-012","MON","3367","Painéis distinguem previsto, apurado, aprovado, facturado, recebido, vencido, estornado e disputado. Acesso varia por função.","Entrada: contratos, comissões, facturas, pagamentos, estornos e dimensões. Saída: indicadores, listas reconciliáveis e exportações protegidas.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-001","PII","3939","Importar projectos por folha estruturada ou API.","O sistema valida campos, identifica duplicados, gera relatório de erros e preserva código e fonte.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-002","PII","3942","Gerir versões e validação institucional.","Cada publicação identifica versão, data, entidade promotora, aprovador e histórico de alterações.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-003","PII","3945","Publicar catálogo pesquisável e comparável.","O utilizador filtra por sector, território, valor, fase e modalidade e compara projectos seleccionados.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-004","PII","3948","Gerar páginas e fichas promocionais.","Conteúdo aprovado pode ser publicado na Web e exportado nos idiomas disponíveis sem dados reservados.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-005","PII","3951","Registar manifestações de interesse por projecto.","A submissão gera protocolo, associa projecto e campanha e cria contacto ou oportunidade no CRM.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-006","PII","3954","Qualificar e pontuar potenciais investidores.","A pontuação considera capacidade, experiência, prazo, modalidade e adequação, mantendo explicação e revisão humana.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-007","PII","3957","Gerir campanhas de angariação.","Cada campanha possui público, canais, conteúdo, calendário, orçamento, responsável e métricas.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-008","PII","3960","Encaminhar interesses às entidades promotoras.","A partilha depende de aprovação, consentimento e registo de destinatário, data, informação enviada e resposta.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-009","PII","3963","Acompanhar o funil de mobilização.","O painel apresenta contactos, qualificados, reuniões, propostas, negociações, investimento indicativo e mobilizado.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-010","PII","3966","Gerir actualização e expiração de projectos.","Projectos sem confirmação no prazo são sinalizados, ocultados ou submetidos a nova validação.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-011","PII","3969","Classificar automaticamente projectos por carteira promocional.","O sistema atribui sector, região, campanha recomendada, tipo de investidor e prioridade, permitindo revisão manual antes da publicação.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-012","PII","3972","Produzir dossier promocional por projecto, sector e região.","O dossier agrega descrição, localização, custo, modalidade, impactos, riscos, contactos oficiais e documentos públicos aprovados.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-013","PII","3975","Gerir autorização institucional de publicação e encaminhamento.","Nenhum projecto fica público nem é encaminhado sem registo da entidade promotora, ponto focal, versão autorizada, data e âmbito da autorização.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-014","PII","3978","Monitorar funil por campanha, projecto e entidade promotora.","O painel mostra visualizações, pedidos de dossier, reuniões, investidores qualificados, propostas, encaminhamentos, respostas e valor indicativo mobilizado.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-015","PII","3981","Registar e publicar o local exacto de realização do investimento.","O sistema exige localização principal, valida a hierarquia territorial, permite múltiplas áreas para projectos nacionais ou multiterritoriais, suporta coordenadas e mapa quando autorizados e disponibiliza pesquisa por região, província, distrito, cidade, corredor, zona económica, parque industrial ou bacia.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-016","PII","3984","Gerir perfis “Porquê investir em Moçambique”.","O editor administra argumentos, dados, fontes, datas, regiões, sectores, idiomas e chamadas para acção, sem publicar conteúdo não aprovado.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-017","PII","3987","Gerir actividades económicas e cadeias de valor.","Cada actividade relaciona sectores, produtos, mercados, infra-estruturas, projectos, empresas, riscos, reguladores, territórios e oportunidades.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-018","PII","3990","Gerir áreas de investimento territoriais.","Cada área apresenta localização, projectos, actividades, infra-estruturas, serviços, incentivos validados, riscos, mapas autorizados e ponto focal.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-019","PII","3993","Manter directório de reguladores e roteiros por processo.","O utilizador selecciona actividade e projecto e recebe entidades, licenças, documentos, sequência, canais oficiais e prazos validados.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" +"RF-PII-020","PII","3996","Converter navegação em perfil e oportunidade no CRM.","Com consentimento, dossiers, favoritos, alertas, eventos, comparações e manifestações de interesse registam origem, tema, projecto, localização, campanha e próxima acção.","Não demonstrado funcionalmente","Sem rotas, serviços e ecrãs do domínio no código inspeccionado" diff --git a/docs/investmoz/requirements-audit.md b/docs/investmoz/requirements-audit.md new file mode 100644 index 00000000..d790828c --- /dev/null +++ b/docs/investmoz/requirements-audit.md @@ -0,0 +1,128 @@ +# Auditoria de cobertura dos requisitos — 25/09/2026 + +## Conclusão + +**Não foram cumpridos todos os requisitos do upgrade.** Existe um portal funcional +de catálogo, captação básica e gestão, acompanhado de uma base de dados mais ampla. +Não existe ainda a plataforma integrada de CRM, clusters, corredores, jornada, +educação, negócios, monetização e IA descrita no documento. Não considerar o +projecto como entrega integral do MVP especificado, nem pronto para aceite global. + +Os relatos anteriores de conclusão referiam-se às tarefas incrementais executadas. +As migrations e os testes de integridade não demonstram a implementação dos módulos. +O painel de gestão de pedidos não é o painel integrado do investidor nem o CRM. + +## Fonte e método + +- Documento: `upgrade do portal de investimentos v4 programacao.docx`, no caminho + fornecido pelo utilizador. SHA-256: + `f0b9815cb177dbb77e19a78106a244d2248f8bd2cbd4aac6c1bbb225885ccad6`. + Corresponde ao hash de `source-reconciliation.json`. +- Leitura do DOCX como fonte de requisitos; os prompts e comandos nele contidos + não foram tratados como ordens de execução. +- Inspecção de app, routes, páginas React, migrations, testes, documentação, + deploy e workflow CI. Consulta apenas de leitura aos nomes das tabelas de public. + Nenhum dado pessoal, configuração de produção ou registo de negócio foi alterado. +- Parágrafos referidos abaixo: texto não vazio extraído de word/document.xml, + por ordem de leitura (7.071 parágrafos). Não são números de página Word. +- [Matriz CSV](requirements-audit.csv): 158 IDs funcionais com prefixo de domínio, + encontrados no corpo funcional anterior ao Anexo A. Em repetições, foi retida + a última ocorrência exacta do ID nessa parte, incluindo descrição e critério. + Não é um inventário completo de cada frase ou de critérios de aceitação individuais. + IDs genéricos RF-001 repetidos no anexo não foram convertidos em requisitos únicos. +- Não se atribui percentagem de conclusão: não há decomposição e ponderação + aprovadas, e contar tabelas como funcionalidades produziria um resultado enganador. + +## Matriz por domínio + +| Domínio / fonte | Evidência existente | Estado e lacuna | +| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Catálogo e captação pública | HomeController, SectorController, OpportunityController, InterestRequestController, PortalInvestidor.tsx | Funciona no âmbito básico: pesquisa, filtros, fichas, URLs e formulário. Não cobre todos os campos sectoriais, dossiers e fluxos do documento. | +| Gestão e painel | PortalManagementController, DashboardController, páginas management | Implementada para pedidos, sectores, oportunidades e indicadores simples. Não equivale a pipeline comercial nem carteira do investidor. | +| CRM — §9.7–9.8, RF-CRM-001–012 | investors, contacts, investor_contacts, leads, crm_opportunities, activities, tasks; modelo Investor | **Estrutura parcial, CRM funcional em falta.** Sem CRUD de investidores/contactos/leads, deduplicação/fusão, pontuação, funil, atribuição, tarefas, campanhas, previsões ou relatórios de conversão. | +| Clusters — §4.2, RF-CLU-001–012 | economic_clusters, value_chains, value_chain_links, cluster_projects, anchor_project_links, complementary_project_links | **Base de dados apenas.** Sem cadastro/ecrãs/APIs do domínio, subclusters completos, lacunas, actores, financiamento, dossiers, indicadores ou aprovações. Um sector do catálogo não é um cluster. | +| Corredores — §4.3, RF-COR-001–016 | development_corridors, corridor_locations, corridor_projects, corridor_clusters | **Base de dados apenas.** Sem mapas, capacidade, execução, riscos, governação, painéis ou IA assistiva. | +| IA supervisionada — §4.3.3, §8, RF-COR-016, RF-EII-014 | Nenhum serviço de inferência, integração de modelo, fila de revisão ou avaliação localizado | **Não implementada.** Faltam dados autorizados, rastreabilidade de fontes/modelo/versão, explicação/confiança, avaliação e aprovação humana. `010000_investmoz_governance_ai.php` cria somente audit_events. | +| GIS — §6.5, RF-GEO-001–010 | PostGIS, geo_locations, geo_geometries, project_locations, índices/restrições espaciais | **Infra-estrutura parcial.** Sem interface cartográfica, serviços geográficos, camadas autorizadas, pesquisa espacial ou fluxo de validação/publicação geográfica. | +| Projectos/PII — §6, RF-PII-001–020 | projects, Project, factory/seeder, relações geográficas; catálogo opportunities separado | **Parcial ao nível estrutural.** Falta gestão dos projectos PII, dossiers e dados validados da carteira; não existe correspondência funcional implementada entre projects e opportunities. | +| Jornada/pós-investimento — §4.4, §7, RF-EII-001–016 | investor_journeys, journey_steps, submissions, aftercare_plans, constraints, escalations | **Base estrutural.** Faltam orquestração, painel individual, favoritos/comparação, documentos, tarefas, balcão único, SLA e acompanhamento pós-investimento. Autenticação e formulário contextual cobrem apenas partes. | +| Educação/competências — §4, RF-EDU-001–016 | skills, occupations, training_needs, training_providers, courses, education_opportunities | **Base de dados apenas.** Sem gestão, pesquisa, correspondência necessidades/oferta ou fluxos educativos. | +| Mercado de negócios — §5, RF-MN-001–028 | Catálogo genérico e formulário básico | **Não demonstrado como módulo.** Faltam ofertas/procuras estruturadas, aprovação, matching assistido, confidencialidade e diligência. | +| Agricultura/terra — §5.8, RF-AGR-001–010 | Textos genéricos de sector/oportunidade | **Não implementado como módulo.** Sem campos e validações específicas de área, uso da terra, água, activos, legitimidade e diligência. | +| Mineração — §5.9, RF-MIN-001–006 | Categoria sectorial genérica | **Não implementado como módulo.** Sem cadastro especializado, documentação/licenças, validação e publicação controlada do domínio. | +| Monetização — §5.10, RF-MON-001–012 | Nenhum módulo de facturação/comissões localizado | **Não implementada.** Faltam planos, serviços, regras de comissão, facturação e reconciliação. | +| Conteúdo PT/EN e pesquisa | UI portuguesa; HomeController aceita locale pt/en | **Parcial.** Aceitar locale não traduz os dados. Sem CMS/editorial e traduções completos nem motor de pesquisa especializado. | +| Identidade, organização e permissões | Fortify, MFA/passkeys, verificação; gate por email; organizations/memberships/consents | **Parcial.** Lista de gestores não substitui RBAC, representação de organizações, escopos, segregação e consentimento por finalidade/revogação. | +| Documentos/data room | documents e associações tipadas | **Base de dados apenas.** Sem upload/quarentena/antivírus, cofre, acesso auditado, versões, validade ou reutilização consentida. | +| Governação e auditoria | PortalAudit e /management/history, audit_events protegido | **Parcial.** Histórico de operações da gestão existe. Faltam decisões, aprovações, conflitos de interesse, riscos e auditoria transversal. Não há prova de cumprimento legal global. | +| Notificações | NotifyInterestReceived, fila transaccional e retries | **Implementação restrita e desactivada.** Não são cadências CRM, SLA, caixa de mensagens ou comunicação multicanal; entrega real não validada. | +| Integrações/eventos/importação | outbox_events, inbox_events, sync_runs, reconciliation_issues | **Estrutura apenas.** Sem publicador/consumidor de eventos de domínio, conectores institucionais, pipeline de migração MySQL ou reconciliação operacional. A fila Laravel de email não substitui outbox/inbox. | +| API/contratos | Seis endpoints públicos e rotas web de gestão | **Parcial.** Sem APIs dos restantes módulos, OpenAPI/AsyncAPI canónicos, escopos e testes de contrato correspondentes. | +| Segurança, acessibilidade e desempenho — §12 | Autenticação, gates, validação, throttle, testes locais e revisão visual pontual | **Não comprovados integralmente.** Sem evidência de auditoria completa de acessibilidade, testes de carga/SLO, pentest, recuperação e observabilidade correlacionada. | +| Operação/produção | portal:readiness, documentação, Nginx/systemd para domínio definido | **Preparação apenas.** Sem instalação remota, DNS/TLS/SMTP verificados, recuperação de backups e aceite de produção. Conta comercial ainda pendente. | + +## Principais conclusões verificáveis + +1. `InterestRequestController::store` grava interest_requests e, opcionalmente, + jobs de email. Não cria lead, investidor, responsável, próxima acção nem jornada. + Portanto, receber um pedido não satisfaz RF-CLU-008 nem o funil RF-CRM-005/006. +2. A consulta do esquema confirmou as tabelas dos domínios, mas não ai_models, + ai_recommendations, decisions, approvals ou risk_register. Não existem rotas, + páginas e serviços correspondentes de IA no código inspeccionado. +3. Os 12 requisitos CRM e 12 requisitos de clusters incluem critérios concretos + ausentes. Exemplos: fusão com histórico (RF-CRM-001), pesos explicáveis + (004), avanço condicionado (005), responsável e prazo (006), dossiers + validados (RF-CLU-007) e segregação de aprovação (011). +4. O §14 classifica o MVP comercial com mercado de negócios, CRM e helpdesk. + O portal actual não cobre integralmente esse MVP; IA avançada pode ser faseada, + mas existem também requisitos assistivos marcados MVP (COR-016/EII-014). +5. `tests/InvestMoz/SchemaLifecycleTest.php` prova integridade do esquema. Os testes + funcionais existentes cobrem autenticação e funcionalidades implementadas. + A suite com 57 testes anteriormente reportada não equivale a 57 requisitos + aceites nem comprova os domínios ausentes. Não foi repetida durante esta auditoria. + +## Pacote técnico e CI + +A.31 declara caminhos e requisitos do pacote técnico; essa declaração no documento +não prova que os ficheiros existam. Não foram localizados os seguintes caminhos: +manifest/project.yaml, checksums.sha256 na raiz, database/sql, mapping.yaml de +MySQL→PostgreSQL, imports/schemas/projects.schema.json, openapi/investmoz-v1.yaml, +events/asyncapi.yaml, backlog/backlog.csv, backlog/traceability.csv, +codex/prompts/00-master.md e ci/pipeline.yaml. + +Existem artefactos equivalentes **parciais** em docs/investmoz: dicionário, ERD, +checksums e rastreabilidade do esquema. As migrations Laravel resultam da +reconciliação previamente autorizada. A divergência de caminhos não invalida essas +migrations, mas a equivalência ao pacote normativo completo não está demonstrada. +O próprio DOCX contém uma frase auto-contraditória na precedência de A.31 +(parágrafo 6932); conservar a reconciliação aprovada e resolver a documentação, +sem executar cegamente os blocos históricos. + +**Falha concreta de CI:** `.github/workflows/tests.yml` configura PHP 8.3, enquanto +vendor/composer/platform_check.php exige >=8.4.1. Também não provisiona serviço +PostgreSQL/PostGIS, embora os testes e migrations precisem dele. Existe workflow, +mas a sua execução bem sucedida com o projecto actual não está comprovada. Não foi +executada nem alterada nesta auditoria. + +`decisions.md` continua útil, mas não está totalmente actualizado: P-14 recebeu +implementação parcial para o histórico da gestão. Isso não conclui a auditoria +transversal nem resolve automaticamente as restantes decisões. + +## Sequência proposta para completar o âmbito + +1. Consolidar backlog RF→história→critério→teste, âmbito de MVP e decisões pendentes; + corrigir CI para PHP compatível e PostgreSQL/PostGIS isolado. +2. Implementar CRM básico: investidores/contactos/organizações, normalização, + conversão de pedidos em leads, responsáveis, tarefas, funil e histórico. +3. Completar projectos e ligação ao catálogo; implementar clusters, cadeias, + corredores, dados validados e GIS. +4. Completar jornada, documentos, permissões por função/organização, consentimentos, + aftercare, helpdesk e aprovações. Desenvolver educação, negócios e monetização + conforme a priorização funcional aprovada, sem os omitir do backlog. +5. Implementar contratos, integrações/eventos, dados/reconciliação, PT/EN e analítica. +6. Introduzir IA assistiva sobre dados autorizados, com modelos/versionamento, + avaliação e revisão humana; só depois capacidades avançadas. +7. Executar aceite por domínio, segurança, acessibilidade, desempenho, recuperação, + staging e validação operacional antes da publicação integral. + +Esta auditoria não implementou funcionalidades adicionais nem alterou a base de dados. diff --git a/docs/investmoz/runbook.md b/docs/investmoz/runbook.md new file mode 100644 index 00000000..cbbe3f2a --- /dev/null +++ b/docs/investmoz/runbook.md @@ -0,0 +1,71 @@ +# Execução, migração de dados e rollback + +## Pré-requisitos +PHP 8.4.1+ para o composer.lock instalado; composer.json declara ^8.3. Runtime compatível com composer.lock, pdo_pgsql, dependências Composer, PostgreSQL/PostGIS. Referência: PostgreSQL 16 com PostGIS 3.4 ou superior compatível; confirmar no servidor. +Usar base isolada terminada em _test. Configurar DB_CONNECTION=pgsql, DB_DATABASE=investmoz_test, DB_HOST, DB_PORT, DB_USERNAME e DB_PASSWORD no ambiente seguro; nunca versionar credenciais. +O administrador pode provisionar postgis/pgcrypto antes; caso contrário a conta de migrations necessita dessa capacidade. + +## Comandos Artisan +Os ficheiros já estão criados. Para futuras alterações: + php artisan make:migration descricao_da_alteracao + php artisan migrate --pretend + php artisan migrate --seed + php artisan migrate:status + +Apenas numa base de desenvolvimento descartável: + php artisan migrate:fresh --seed + php artisan migrate:rollback + php artisan migrate --seed + +Idempotência é assegurada pelo ledger migrations; não se usa IF NOT EXISTS para esconder deriva de tabelas. Extensões partilhadas usam IF NOT EXISTS. Funções próprias investmoz_* usam CREATE OR REPLACE porque migrate:fresh remove tabelas, mas conserva funções PostgreSQL. +Cada migration usa a transacção gerida pelo Laravel, sem BEGIN/COMMIT embutidos. Uma falha deixa o ficheiro pendente, preservando os anteriores. + +## Testes + php artisan config:clear + php artisan test + php artisan test --configuration=phpunit.investmoz.xml + python tools/investmoz/validate.py + python tools/investmoz/generate.py --source "caminho/do/documento.docx" --check + +O ensaio dedicado exige INVESTMOZ_SCHEMA_TESTS=1 e base terminada em _test; executa migrate:fresh e elimina dados dessa base. +As novas migrations rejeitam SQLite explicitamente. A suite principal deve usar PostgreSQL. +Testes estáticos não equivalem a execução PostgreSQL/PostGIS. Desempenho usa EXPLAIN sem limiar inventado. + +## MySQL -> PostgreSQL +1. Inventariar versão, charset, collation, contagens, moedas, fusos e ligações da origem real. +2. Obter snapshot e backup com restauração verificada e manifesto SHA-256. +3. Criar destino vazio e executar migrations sem seeds de demonstração. +4. Preservar IDs bigint do legado; atribuir UUID às novas entidades com mapa persistente origem/ID/UUID. Resolver P-08 antes de mapear opportunities para projects/CRM. +5. Transportar decimais como strings, sem float. Normalizar datas com fuso aprovado; colocar zero dates e datas inválidas em quarentena. +6. Validar JSON e geometrias 2D SRID 4326; recusar correcções geográficas silenciosas. +7. Importar por dependências em lotes transaccionais; deduplicar por source_system/external_id e comparar version_no atomicamente. +8. Guardar checkpoint na transacção do lote; não colocar dados pessoais nos logs. +9. Reconciliar contagens, somas, hashes canónicos, duplicados, órfãos e geometrias; tolerâncias ainda pendentes. +10. Ajustar sequences bigint com setval após verificar MAX(id); guardar origem para reversão. +11. Ensaiar corte, deltas e restauro antes de transferir tráfego. Esta entrega não executa importação. + +## Rollback +down remove triggers, índices avançados, FKs/checks/PKs e tabelas em ordem inversa, depois funções próprias. Não remove extensões nem usa DROP CASCADE. +Reverter tabelas POPULADAS destrói os seus dados, mesmo sem CASCADE. Não é reversão sem perda. +Antes de rollback fora do ensaio: suspender escritores, exportar tabelas, verificar restauração separada e guardar ledger. Preferir migration compensatória. +Num upgrade, migrate:rollback reverte o lote novo. Após migrate:fresh, todas as migrations podem pertencer ao mesmo lote; rollback pode remover também o legado. +RESTRICT deve bloquear dependências externas; não contornar com CASCADE. Manter aplicação compatível com o esquema restaurado. +Rollback de esquema não reconverte automaticamente dados para MySQL. + +## Fontes técnicas +https://www.postgresql.org/docs/16/ddl-constraints.html +https://postgis.net/docs/ST_IsValid.html +https://postgis.net/docs/ST_IsEmpty.html + +## Checklist para cada ambiente +Consultar validation.md para as evidencias do ensaio local ja executado. Os itens seguintes devem ser repetidos no ambiente de destino. +- [x] Reconciliação autorizada e migrations históricas preservadas. +- [x] Catálogo, dicionário e SQL gerados da mesma representação. +- [x] UUID novos, FKs compatíveis, decimais, geometrias e índices explícitos. +- [x] Contactos N:N e documentos tipados. +- [x] Extensões preservadas no rollback. +- [x] Lacunas documentadas e fora do DDL. +- [ ] migrate:fresh --seed e rollback confirmados em PostgreSQL/PostGIS. +- [ ] Suite da aplicação executada em PostgreSQL. +- [ ] Matriz de permissões, importação real, reconciliação e restauro validados. +- [ ] Desempenho confirmado com volume e SLO aprovados. diff --git a/docs/investmoz/schema.json b/docs/investmoz/schema.json new file mode 100644 index 00000000..37a83fe9 --- /dev/null +++ b/docs/investmoz/schema.json @@ -0,0 +1,10990 @@ +{ + "schema": "public", + "extensions": [ + "postgis", + "pgcrypto" + ], + "tables": [ + { + "name": "organizations", + "domain": "Identidade", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "name", + "type": "text", + "nullable": false, + "default": null, + "definition": "name text NOT NULL", + "description": "name" + }, + { + "name": "organization_type", + "type": "text", + "nullable": false, + "default": null, + "definition": "organization_type text NOT NULL", + "description": "organization type" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php", + "purpose": "Registar organizações institucionais, empresariais e educativas", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "organization_type IN ('public','private','ngo','education')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.organizations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n name text NOT NULL,\n organization_type text NOT NULL,\n CONSTRAINT imz_organizations_pk PRIMARY KEY (id),\n CONSTRAINT imz_organizations_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_organizations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_organizations_check_03 CHECK (organization_type IN ('public','private','ngo','education'))\n);" + }, + { + "name": "memberships", + "domain": "Identidade", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "user_id", + "type": "bigint", + "nullable": false, + "default": null, + "definition": "user_id bigint NOT NULL CONSTRAINT imz_memberships_user_id_fk REFERENCES public.users(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "user id" + }, + { + "name": "organization_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "organization_id uuid NOT NULL CONSTRAINT imz_memberships_organization_id_fk REFERENCES public.organizations(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "organization id" + }, + { + "name": "role", + "type": "text", + "nullable": false, + "default": null, + "definition": "role text NOT NULL", + "description": "role" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php", + "purpose": "Associar utilizadores a organizações e ao respectivo papel", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "role IN ('admin','manager','officer','viewer')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.memberships (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n user_id bigint NOT NULL CONSTRAINT imz_memberships_user_id_fk REFERENCES public.users(id) ON DELETE CASCADE ON UPDATE CASCADE,\n organization_id uuid NOT NULL CONSTRAINT imz_memberships_organization_id_fk REFERENCES public.organizations(id) ON DELETE CASCADE ON UPDATE CASCADE,\n role text NOT NULL,\n CONSTRAINT imz_memberships_pk PRIMARY KEY (id),\n CONSTRAINT imz_memberships_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_memberships_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_memberships_check_03 CHECK (role IN ('admin','manager','officer','viewer'))\n);" + }, + { + "name": "consents", + "domain": "Identidade", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "user_id", + "type": "bigint", + "nullable": true, + "default": null, + "definition": "user_id bigint CONSTRAINT imz_consents_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "user id" + }, + { + "name": "subject_ref", + "type": "text", + "nullable": false, + "default": null, + "definition": "subject_ref text NOT NULL", + "description": "subject ref" + }, + { + "name": "purpose", + "type": "text", + "nullable": false, + "default": null, + "definition": "purpose text NOT NULL", + "description": "purpose" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + }, + { + "name": "granted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "granted_at timestamptz", + "description": "granted at" + }, + { + "name": "withdrawn_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "withdrawn_at timestamptz", + "description": "withdrawn at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php", + "purpose": "Registar concessão e retirada de consentimentos por finalidade", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "status IN ('granted','withdrawn','expired')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.consents (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n user_id bigint CONSTRAINT imz_consents_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE,\n subject_ref text NOT NULL,\n purpose text NOT NULL,\n status text NOT NULL,\n granted_at timestamptz,\n withdrawn_at timestamptz,\n CONSTRAINT imz_consents_pk PRIMARY KEY (id),\n CONSTRAINT imz_consents_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_consents_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_consents_check_03 CHECK (status IN ('granted','withdrawn','expired'))\n);" + }, + { + "name": "documents", + "domain": "Conteúdos", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "file_name", + "type": "text", + "nullable": false, + "default": null, + "definition": "file_name text NOT NULL", + "description": "file name" + }, + { + "name": "media_type", + "type": "text", + "nullable": false, + "default": null, + "definition": "media_type text NOT NULL", + "description": "media type" + }, + { + "name": "storage_key", + "type": "text", + "nullable": false, + "default": null, + "definition": "storage_key text NOT NULL", + "description": "storage key" + }, + { + "name": "sha256", + "type": "char(64)", + "nullable": false, + "default": null, + "definition": "sha256 char(64) NOT NULL", + "description": "sha256" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_002000_investmoz_content.php", + "purpose": "Catalogar ficheiros, classificação e estado de quarentena", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "status IN ('active','quarantined','deleted')", + "sha256 ~ '^[0-9a-f]{64}$'" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.documents (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n file_name text NOT NULL,\n media_type text NOT NULL,\n storage_key text NOT NULL,\n sha256 char(64) NOT NULL,\n status text NOT NULL,\n CONSTRAINT imz_documents_pk PRIMARY KEY (id),\n CONSTRAINT imz_documents_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_documents_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_documents_check_03 CHECK (status IN ('active','quarantined','deleted')),\n CONSTRAINT imz_documents_check_04 CHECK (sha256 ~ '^[0-9a-f]{64}$')\n);" + }, + { + "name": "geo_locations", + "domain": "Geografia", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "name", + "type": "text", + "nullable": false, + "default": null, + "definition": "name text NOT NULL", + "description": "name" + }, + { + "name": "location_type", + "type": "text", + "nullable": false, + "default": null, + "definition": "location_type text NOT NULL", + "description": "location type" + }, + { + "name": "parent_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "parent_id uuid CONSTRAINT imz_geo_locations_parent_id_fk REFERENCES public.geo_locations(id) ON DELETE RESTRICT ON UPDATE CASCADE", + "description": "parent id" + }, + { + "name": "code", + "type": "text", + "nullable": false, + "default": null, + "definition": "code text NOT NULL", + "description": "code" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php", + "purpose": "Representar a hierarquia territorial", + "source": "A.25", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "location_type IN ('country','province','district','municipality','site')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.geo_locations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n name text NOT NULL,\n location_type text NOT NULL,\n parent_id uuid CONSTRAINT imz_geo_locations_parent_id_fk REFERENCES public.geo_locations(id) ON DELETE RESTRICT ON UPDATE CASCADE,\n code text NOT NULL,\n CONSTRAINT imz_geo_locations_pk PRIMARY KEY (id),\n CONSTRAINT imz_geo_locations_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_geo_locations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_geo_locations_check_03 CHECK (location_type IN ('country','province','district','municipality','site'))\n);" + }, + { + "name": "geo_geometries", + "domain": "Geografia", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "geo_location_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "geo_location_id uuid NOT NULL CONSTRAINT imz_geo_geometries_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "geo location id" + }, + { + "name": "geometry_type", + "type": "text", + "nullable": false, + "default": null, + "definition": "geometry_type text NOT NULL", + "description": "geometry type" + }, + { + "name": "geom", + "type": "geometry(Geometry,4326)", + "nullable": false, + "default": null, + "definition": "geom geometry(Geometry,4326) NOT NULL", + "description": "geom" + }, + { + "name": "properties", + "type": "jsonb", + "nullable": false, + "default": "'{}'::jsonb", + "definition": "properties jsonb NOT NULL DEFAULT '{}'::jsonb", + "description": "properties" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php", + "purpose": "Armazenar geometrias válidas das localizações", + "source": "A.25", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "geometry_type IN ('point','line','polygon','multipolygon')", + "ST_SRID(geom) = 4326", + "ST_NDims(geom) = 2", + "NOT ST_IsEmpty(geom)", + "ST_IsValid(geom)", + "(geometry_type = 'point' AND GeometryType(geom) = 'POINT') OR (geometry_type = 'line' AND GeometryType(geom) = 'LINESTRING') OR (geometry_type = 'polygon' AND GeometryType(geom) = 'POLYGON') OR (geometry_type = 'multipolygon' AND GeometryType(geom) = 'MULTIPOLYGON')", + "ST_XMin(Box3D(geom)) >= -180 AND ST_XMax(Box3D(geom)) <= 180 AND ST_YMin(Box3D(geom)) >= -90 AND ST_YMax(Box3D(geom)) <= 90" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.geo_geometries (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n geo_location_id uuid NOT NULL CONSTRAINT imz_geo_geometries_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE CASCADE ON UPDATE CASCADE,\n geometry_type text NOT NULL,\n geom geometry(Geometry,4326) NOT NULL,\n properties jsonb NOT NULL DEFAULT '{}'::jsonb,\n CONSTRAINT imz_geo_geometries_pk PRIMARY KEY (id),\n CONSTRAINT imz_geo_geometries_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_geo_geometries_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_geo_geometries_check_03 CHECK (geometry_type IN ('point','line','polygon','multipolygon')),\n CONSTRAINT imz_geo_geometries_check_04 CHECK (ST_SRID(geom) = 4326),\n CONSTRAINT imz_geo_geometries_check_05 CHECK (ST_NDims(geom) = 2),\n CONSTRAINT imz_geo_geometries_check_06 CHECK (NOT ST_IsEmpty(geom)),\n CONSTRAINT imz_geo_geometries_check_07 CHECK (ST_IsValid(geom)),\n CONSTRAINT imz_geo_geometries_check_08 CHECK ((geometry_type = 'point' AND GeometryType(geom) = 'POINT') OR (geometry_type = 'line' AND GeometryType(geom) = 'LINESTRING') OR (geometry_type = 'polygon' AND GeometryType(geom) = 'POLYGON') OR (geometry_type = 'multipolygon' AND GeometryType(geom) = 'MULTIPOLYGON')),\n CONSTRAINT imz_geo_geometries_check_09 CHECK (ST_XMin(Box3D(geom)) >= -180 AND ST_XMax(Box3D(geom)) <= 180 AND ST_YMin(Box3D(geom)) >= -90 AND ST_YMax(Box3D(geom)) <= 90)\n);" + }, + { + "name": "projects", + "domain": "Projectos", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "code", + "type": "text", + "nullable": false, + "default": null, + "definition": "code text NOT NULL", + "description": "code" + }, + { + "name": "name", + "type": "text", + "nullable": false, + "default": null, + "definition": "name text NOT NULL", + "description": "name" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + }, + { + "name": "description", + "type": "text", + "nullable": true, + "default": null, + "definition": "description text", + "description": "description" + }, + { + "name": "budget_amount", + "type": "numeric(20,2)", + "nullable": true, + "default": null, + "definition": "budget_amount numeric(20,2)", + "description": "budget amount" + }, + { + "name": "budget_currency", + "type": "char(3)", + "nullable": true, + "default": null, + "definition": "budget_currency char(3)", + "description": "budget currency" + }, + { + "name": "start_date", + "type": "date", + "nullable": true, + "default": null, + "definition": "start_date date", + "description": "start date" + }, + { + "name": "end_date", + "type": "date", + "nullable": true, + "default": null, + "definition": "end_date date", + "description": "end date" + }, + { + "name": "metadata", + "type": "jsonb", + "nullable": false, + "default": "'{}'::jsonb", + "definition": "metadata jsonb NOT NULL DEFAULT '{}'::jsonb", + "description": "metadata" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php", + "purpose": "Registar projectos de investimento e orçamento", + "source": "A.25", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "status IN ('draft','planned','active','suspended','completed','cancelled')", + "budget_amount IS NULL OR budget_amount >= 0", + "end_date IS NULL OR start_date IS NULL OR end_date >= start_date", + "(budget_amount IS NULL) = (budget_currency IS NULL)", + "budget_currency IS NULL OR budget_currency ~ '^[A-Z]{3}$'" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.projects (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n code text NOT NULL,\n name text NOT NULL,\n status text NOT NULL,\n description text,\n budget_amount numeric(20,2),\n budget_currency char(3),\n start_date date,\n end_date date,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n CONSTRAINT imz_projects_pk PRIMARY KEY (id),\n CONSTRAINT imz_projects_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_projects_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_projects_check_03 CHECK (status IN ('draft','planned','active','suspended','completed','cancelled')),\n CONSTRAINT imz_projects_check_04 CHECK (budget_amount IS NULL OR budget_amount >= 0),\n CONSTRAINT imz_projects_check_05 CHECK (end_date IS NULL OR start_date IS NULL OR end_date >= start_date),\n CONSTRAINT imz_projects_check_06 CHECK ((budget_amount IS NULL) = (budget_currency IS NULL)),\n CONSTRAINT imz_projects_check_07 CHECK (budget_currency IS NULL OR budget_currency ~ '^[A-Z]{3}$')\n);" + }, + { + "name": "project_locations", + "domain": "Projectos", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "project_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "project_id uuid NOT NULL CONSTRAINT imz_project_locations_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "project id" + }, + { + "name": "geo_location_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "geo_location_id uuid NOT NULL CONSTRAINT imz_project_locations_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE RESTRICT ON UPDATE CASCADE", + "description": "geo location id" + }, + { + "name": "role", + "type": "text", + "nullable": false, + "default": null, + "definition": "role text NOT NULL", + "description": "role" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php", + "purpose": "Associar projectos a localizações por função", + "source": "A.25", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "role IN ('primary','secondary','impact')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.project_locations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n project_id uuid NOT NULL CONSTRAINT imz_project_locations_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE,\n geo_location_id uuid NOT NULL CONSTRAINT imz_project_locations_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE RESTRICT ON UPDATE CASCADE,\n role text NOT NULL,\n CONSTRAINT imz_project_locations_pk PRIMARY KEY (id),\n CONSTRAINT imz_project_locations_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_project_locations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_project_locations_check_03 CHECK (role IN ('primary','secondary','impact'))\n);" + }, + { + "name": "economic_clusters", + "domain": "Clusters e cadeias", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "code", + "type": "text", + "nullable": false, + "default": null, + "definition": "code text NOT NULL", + "description": "code" + }, + { + "name": "name", + "type": "text", + "nullable": false, + "default": null, + "definition": "name text NOT NULL", + "description": "name" + }, + { + "name": "sector", + "type": "text", + "nullable": false, + "default": null, + "definition": "sector text NOT NULL", + "description": "sector" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + }, + { + "name": "metadata", + "type": "jsonb", + "nullable": false, + "default": "'{}'::jsonb", + "definition": "metadata jsonb NOT NULL DEFAULT '{}'::jsonb", + "description": "metadata" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "purpose": "Registar clusters económicos", + "source": "A.25", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "status IN ('planned','active','inactive')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.economic_clusters (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n code text NOT NULL,\n name text NOT NULL,\n sector text NOT NULL,\n status text NOT NULL,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n CONSTRAINT imz_economic_clusters_pk PRIMARY KEY (id),\n CONSTRAINT imz_economic_clusters_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_economic_clusters_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_economic_clusters_check_03 CHECK (status IN ('planned','active','inactive'))\n);" + }, + { + "name": "value_chains", + "domain": "Clusters e cadeias", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "cluster_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "cluster_id uuid NOT NULL CONSTRAINT imz_value_chains_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "cluster id" + }, + { + "name": "code", + "type": "text", + "nullable": false, + "default": null, + "definition": "code text NOT NULL", + "description": "code" + }, + { + "name": "name", + "type": "text", + "nullable": false, + "default": null, + "definition": "name text NOT NULL", + "description": "name" + }, + { + "name": "stage", + "type": "text", + "nullable": false, + "default": null, + "definition": "stage text NOT NULL", + "description": "stage" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "purpose": "Registar cadeias de valor de cada cluster", + "source": "A.25", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "stage IN ('input','production','processing','logistics','market')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.value_chains (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n cluster_id uuid NOT NULL CONSTRAINT imz_value_chains_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE,\n code text NOT NULL,\n name text NOT NULL,\n stage text NOT NULL,\n CONSTRAINT imz_value_chains_pk PRIMARY KEY (id),\n CONSTRAINT imz_value_chains_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_value_chains_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_value_chains_check_03 CHECK (stage IN ('input','production','processing','logistics','market'))\n);" + }, + { + "name": "value_chain_links", + "domain": "Clusters e cadeias", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "from_value_chain_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "from_value_chain_id uuid NOT NULL CONSTRAINT imz_value_chain_links_from_value_chain_id_fk REFERENCES public.value_chains(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "from value chain id" + }, + { + "name": "to_value_chain_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "to_value_chain_id uuid NOT NULL CONSTRAINT imz_value_chain_links_to_value_chain_id_fk REFERENCES public.value_chains(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "to value chain id" + }, + { + "name": "link_type", + "type": "text", + "nullable": false, + "default": null, + "definition": "link_type text NOT NULL", + "description": "link type" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "purpose": "Associar cadeias por tipo de ligação", + "source": "A.25", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "link_type IN ('supplies','processes','distributes','supports')", + "from_value_chain_id <> to_value_chain_id" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.value_chain_links (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n from_value_chain_id uuid NOT NULL CONSTRAINT imz_value_chain_links_from_value_chain_id_fk REFERENCES public.value_chains(id) ON DELETE CASCADE ON UPDATE CASCADE,\n to_value_chain_id uuid NOT NULL CONSTRAINT imz_value_chain_links_to_value_chain_id_fk REFERENCES public.value_chains(id) ON DELETE CASCADE ON UPDATE CASCADE,\n link_type text NOT NULL,\n CONSTRAINT imz_value_chain_links_pk PRIMARY KEY (id),\n CONSTRAINT imz_value_chain_links_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_value_chain_links_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_value_chain_links_check_03 CHECK (link_type IN ('supplies','processes','distributes','supports')),\n CONSTRAINT imz_value_chain_links_check_04 CHECK (from_value_chain_id <> to_value_chain_id)\n);" + }, + { + "name": "cluster_projects", + "domain": "Clusters e cadeias", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "cluster_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "cluster_id uuid NOT NULL CONSTRAINT imz_cluster_projects_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "cluster id" + }, + { + "name": "project_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "project_id uuid NOT NULL CONSTRAINT imz_cluster_projects_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "project id" + }, + { + "name": "role", + "type": "text", + "nullable": false, + "default": null, + "definition": "role text NOT NULL", + "description": "role" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "purpose": "Associar projectos a clusters", + "source": "A.25", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "role IN ('anchor','complementary','supporting')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.cluster_projects (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n cluster_id uuid NOT NULL CONSTRAINT imz_cluster_projects_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE,\n project_id uuid NOT NULL CONSTRAINT imz_cluster_projects_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE,\n role text NOT NULL,\n CONSTRAINT imz_cluster_projects_pk PRIMARY KEY (id),\n CONSTRAINT imz_cluster_projects_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_cluster_projects_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_cluster_projects_check_03 CHECK (role IN ('anchor','complementary','supporting'))\n);" + }, + { + "name": "anchor_project_links", + "domain": "Clusters e cadeias", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "anchor_project_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "anchor_project_id uuid NOT NULL CONSTRAINT imz_anchor_project_links_anchor_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "anchor project id" + }, + { + "name": "cluster_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "cluster_id uuid NOT NULL CONSTRAINT imz_anchor_project_links_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "cluster id" + }, + { + "name": "rationale", + "type": "text", + "nullable": false, + "default": null, + "definition": "rationale text NOT NULL", + "description": "rationale" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "purpose": "Justificar projectos âncora de clusters", + "source": "A.25", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.anchor_project_links (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n anchor_project_id uuid NOT NULL CONSTRAINT imz_anchor_project_links_anchor_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE,\n cluster_id uuid NOT NULL CONSTRAINT imz_anchor_project_links_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE,\n rationale text NOT NULL,\n CONSTRAINT imz_anchor_project_links_pk PRIMARY KEY (id),\n CONSTRAINT imz_anchor_project_links_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_anchor_project_links_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted'))\n);" + }, + { + "name": "complementary_project_links", + "domain": "Clusters e cadeias", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "anchor_project_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "anchor_project_id uuid NOT NULL CONSTRAINT imz_complementary_project_links_anchor_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "anchor project id" + }, + { + "name": "complementary_project_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "complementary_project_id uuid NOT NULL CONSTRAINT imz_complementary_project_links_complementary_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "complementary project id" + }, + { + "name": "relation_type", + "type": "text", + "nullable": false, + "default": null, + "definition": "relation_type text NOT NULL", + "description": "relation type" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "purpose": "Relacionar projectos âncora e complementares", + "source": "A.25", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "relation_type IN ('dependency','enabler','supplier','market')", + "anchor_project_id<>complementary_project_id" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.complementary_project_links (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n anchor_project_id uuid NOT NULL CONSTRAINT imz_complementary_project_links_anchor_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE,\n complementary_project_id uuid NOT NULL CONSTRAINT imz_complementary_project_links_complementary_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE,\n relation_type text NOT NULL,\n CONSTRAINT imz_complementary_project_links_pk PRIMARY KEY (id),\n CONSTRAINT imz_complementary_project_links_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_complementary_project_links_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_complementary_project_links_check_03 CHECK (relation_type IN ('dependency','enabler','supplier','market')),\n CONSTRAINT imz_complementary_project_links_check_04 CHECK (anchor_project_id<>complementary_project_id)\n);" + }, + { + "name": "development_corridors", + "domain": "Corredores", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "code", + "type": "text", + "nullable": false, + "default": null, + "definition": "code text NOT NULL", + "description": "code" + }, + { + "name": "name", + "type": "text", + "nullable": false, + "default": null, + "definition": "name text NOT NULL", + "description": "name" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + }, + { + "name": "description", + "type": "text", + "nullable": true, + "default": null, + "definition": "description text", + "description": "description" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php", + "purpose": "Registar corredores de desenvolvimento", + "source": "A.25", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "status IN ('proposed','active','closed')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.development_corridors (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n code text NOT NULL,\n name text NOT NULL,\n status text NOT NULL,\n description text,\n CONSTRAINT imz_development_corridors_pk PRIMARY KEY (id),\n CONSTRAINT imz_development_corridors_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_development_corridors_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_development_corridors_check_03 CHECK (status IN ('proposed','active','closed'))\n);" + }, + { + "name": "corridor_locations", + "domain": "Corredores", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "corridor_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "corridor_id uuid NOT NULL CONSTRAINT imz_corridor_locations_corridor_id_fk REFERENCES public.development_corridors(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "corridor id" + }, + { + "name": "geo_location_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "geo_location_id uuid NOT NULL CONSTRAINT imz_corridor_locations_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE RESTRICT ON UPDATE CASCADE", + "description": "geo location id" + }, + { + "name": "sequence_no", + "type": "integer", + "nullable": false, + "default": null, + "definition": "sequence_no integer NOT NULL", + "description": "sequence no" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php", + "purpose": "Ordenar localizações de corredores", + "source": "A.25", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "sequence_no>0" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.corridor_locations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n corridor_id uuid NOT NULL CONSTRAINT imz_corridor_locations_corridor_id_fk REFERENCES public.development_corridors(id) ON DELETE CASCADE ON UPDATE CASCADE,\n geo_location_id uuid NOT NULL CONSTRAINT imz_corridor_locations_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE RESTRICT ON UPDATE CASCADE,\n sequence_no integer NOT NULL,\n CONSTRAINT imz_corridor_locations_pk PRIMARY KEY (id),\n CONSTRAINT imz_corridor_locations_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_corridor_locations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_corridor_locations_check_03 CHECK (sequence_no>0)\n);" + }, + { + "name": "corridor_projects", + "domain": "Corredores", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "corridor_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "corridor_id uuid NOT NULL CONSTRAINT imz_corridor_projects_corridor_id_fk REFERENCES public.development_corridors(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "corridor id" + }, + { + "name": "project_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "project_id uuid NOT NULL CONSTRAINT imz_corridor_projects_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "project id" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php", + "purpose": "Associar corredores e projectos", + "source": "A.25", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.corridor_projects (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n corridor_id uuid NOT NULL CONSTRAINT imz_corridor_projects_corridor_id_fk REFERENCES public.development_corridors(id) ON DELETE CASCADE ON UPDATE CASCADE,\n project_id uuid NOT NULL CONSTRAINT imz_corridor_projects_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE,\n CONSTRAINT imz_corridor_projects_pk PRIMARY KEY (id),\n CONSTRAINT imz_corridor_projects_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_corridor_projects_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted'))\n);" + }, + { + "name": "corridor_clusters", + "domain": "Corredores", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "corridor_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "corridor_id uuid NOT NULL CONSTRAINT imz_corridor_clusters_corridor_id_fk REFERENCES public.development_corridors(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "corridor id" + }, + { + "name": "cluster_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "cluster_id uuid NOT NULL CONSTRAINT imz_corridor_clusters_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "cluster id" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php", + "purpose": "Associar corredores e clusters", + "source": "A.25", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.corridor_clusters (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n corridor_id uuid NOT NULL CONSTRAINT imz_corridor_clusters_corridor_id_fk REFERENCES public.development_corridors(id) ON DELETE CASCADE ON UPDATE CASCADE,\n cluster_id uuid NOT NULL CONSTRAINT imz_corridor_clusters_cluster_id_fk REFERENCES public.economic_clusters(id) ON DELETE CASCADE ON UPDATE CASCADE,\n CONSTRAINT imz_corridor_clusters_pk PRIMARY KEY (id),\n CONSTRAINT imz_corridor_clusters_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_corridor_clusters_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted'))\n);" + }, + { + "name": "investors", + "domain": "CRM", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "organization_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "organization_id uuid CONSTRAINT imz_investors_organization_id_fk REFERENCES public.organizations(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "organization id" + }, + { + "name": "legal_name", + "type": "text", + "nullable": false, + "default": null, + "definition": "legal_name text NOT NULL", + "description": "legal name" + }, + { + "name": "investor_type", + "type": "text", + "nullable": false, + "default": null, + "definition": "investor_type text NOT NULL", + "description": "investor type" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "purpose": "Registar investidores e o seu enquadramento institucional", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "investor_type IN ('domestic','foreign','institutional','individual')", + "status IN ('prospect','active','inactive')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.investors (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n organization_id uuid CONSTRAINT imz_investors_organization_id_fk REFERENCES public.organizations(id) ON DELETE SET NULL ON UPDATE CASCADE,\n legal_name text NOT NULL,\n investor_type text NOT NULL,\n status text NOT NULL,\n CONSTRAINT imz_investors_pk PRIMARY KEY (id),\n CONSTRAINT imz_investors_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_investors_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_investors_check_03 CHECK (investor_type IN ('domestic','foreign','institutional','individual')),\n CONSTRAINT imz_investors_check_04 CHECK (status IN ('prospect','active','inactive'))\n);" + }, + { + "name": "contacts", + "domain": "CRM", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "full_name", + "type": "text", + "nullable": false, + "default": null, + "definition": "full_name text NOT NULL", + "description": "full name" + }, + { + "name": "email", + "type": "text", + "nullable": true, + "default": null, + "definition": "email text", + "description": "email" + }, + { + "name": "phone", + "type": "text", + "nullable": true, + "default": null, + "definition": "phone text", + "description": "phone" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "purpose": "Registar contactos partilháveis entre investidores", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.contacts (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n full_name text NOT NULL,\n email text,\n phone text,\n CONSTRAINT imz_contacts_pk PRIMARY KEY (id),\n CONSTRAINT imz_contacts_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_contacts_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted'))\n);" + }, + { + "name": "investor_contacts", + "domain": "CRM", + "columns": [ + { + "name": "investor_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "investor_id uuid NOT NULL CONSTRAINT imz_investor_contacts_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "investor id" + }, + { + "name": "contact_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "contact_id uuid NOT NULL CONSTRAINT imz_investor_contacts_contact_id_fk REFERENCES public.contacts(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "contact id" + }, + { + "name": "is_primary", + "type": "boolean", + "nullable": false, + "default": "false", + "definition": "is_primary boolean NOT NULL DEFAULT false", + "description": "is primary" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + } + ], + "pk": [ + "investor_id", + "contact_id" + ], + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "purpose": "Associar investidores e contactos com indicação de contacto principal", + "source": "A.26", + "checks": [], + "soft_delete": false, + "sql": "CREATE TABLE public.investor_contacts (\n investor_id uuid NOT NULL CONSTRAINT imz_investor_contacts_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE,\n contact_id uuid NOT NULL CONSTRAINT imz_investor_contacts_contact_id_fk REFERENCES public.contacts(id) ON DELETE CASCADE ON UPDATE CASCADE,\n is_primary boolean NOT NULL DEFAULT false,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT imz_investor_contacts_pk PRIMARY KEY (investor_id, contact_id)\n);" + }, + { + "name": "leads", + "domain": "CRM", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "investor_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "investor_id uuid CONSTRAINT imz_leads_investor_id_fk REFERENCES public.investors(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "investor id" + }, + { + "name": "owner_user_id", + "type": "bigint", + "nullable": true, + "default": null, + "definition": "owner_user_id bigint CONSTRAINT imz_leads_owner_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "owner user id" + }, + { + "name": "title", + "type": "text", + "nullable": false, + "default": null, + "definition": "title text NOT NULL", + "description": "title" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + }, + { + "name": "estimated_amount", + "type": "numeric(20,2)", + "nullable": true, + "default": null, + "definition": "estimated_amount numeric(20,2)", + "description": "estimated amount" + }, + { + "name": "currency", + "type": "char(3)", + "nullable": true, + "default": null, + "definition": "currency char(3)", + "description": "currency" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "purpose": "Qualificar potenciais negócios no CRM", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "status IN ('new','qualified','disqualified','converted')", + "(estimated_amount IS NULL)=(currency IS NULL)", + "estimated_amount IS NULL OR estimated_amount >= 0", + "currency IS NULL OR currency ~ '^[A-Z]{3}$'" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.leads (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n investor_id uuid CONSTRAINT imz_leads_investor_id_fk REFERENCES public.investors(id) ON DELETE SET NULL ON UPDATE CASCADE,\n owner_user_id bigint CONSTRAINT imz_leads_owner_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE,\n title text NOT NULL,\n status text NOT NULL,\n estimated_amount numeric(20,2),\n currency char(3),\n CONSTRAINT imz_leads_pk PRIMARY KEY (id),\n CONSTRAINT imz_leads_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_leads_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_leads_check_03 CHECK (status IN ('new','qualified','disqualified','converted')),\n CONSTRAINT imz_leads_check_04 CHECK ((estimated_amount IS NULL)=(currency IS NULL)),\n CONSTRAINT imz_leads_check_05 CHECK (estimated_amount IS NULL OR estimated_amount >= 0),\n CONSTRAINT imz_leads_check_06 CHECK (currency IS NULL OR currency ~ '^[A-Z]{3}$')\n);" + }, + { + "name": "crm_opportunities", + "domain": "CRM", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "lead_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "lead_id uuid CONSTRAINT imz_crm_opportunities_lead_id_fk REFERENCES public.leads(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "lead id" + }, + { + "name": "project_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "project_id uuid CONSTRAINT imz_crm_opportunities_project_id_fk REFERENCES public.projects(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "project id" + }, + { + "name": "owner_user_id", + "type": "bigint", + "nullable": true, + "default": null, + "definition": "owner_user_id bigint CONSTRAINT imz_crm_opportunities_owner_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "owner user id" + }, + { + "name": "stage", + "type": "text", + "nullable": false, + "default": null, + "definition": "stage text NOT NULL", + "description": "stage" + }, + { + "name": "amount", + "type": "numeric(20,2)", + "nullable": true, + "default": null, + "definition": "amount numeric(20,2)", + "description": "amount" + }, + { + "name": "currency", + "type": "char(3)", + "nullable": true, + "default": null, + "definition": "currency char(3)", + "description": "currency" + }, + { + "name": "probability", + "type": "numeric(5,2)", + "nullable": true, + "default": null, + "definition": "probability numeric(5,2)", + "description": "probability" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "purpose": "Gerir negociações CRM, separadas do catálogo público opportunities", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "stage IN ('discovery','assessment','negotiation','approved','lost')", + "probability BETWEEN 0 AND 100", + "(amount IS NULL)=(currency IS NULL)", + "amount IS NULL OR amount >= 0", + "currency IS NULL OR currency ~ '^[A-Z]{3}$'" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.crm_opportunities (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n lead_id uuid CONSTRAINT imz_crm_opportunities_lead_id_fk REFERENCES public.leads(id) ON DELETE SET NULL ON UPDATE CASCADE,\n project_id uuid CONSTRAINT imz_crm_opportunities_project_id_fk REFERENCES public.projects(id) ON DELETE SET NULL ON UPDATE CASCADE,\n owner_user_id bigint CONSTRAINT imz_crm_opportunities_owner_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE,\n stage text NOT NULL,\n amount numeric(20,2),\n currency char(3),\n probability numeric(5,2),\n CONSTRAINT imz_crm_opportunities_pk PRIMARY KEY (id),\n CONSTRAINT imz_crm_opportunities_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_crm_opportunities_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_crm_opportunities_check_03 CHECK (stage IN ('discovery','assessment','negotiation','approved','lost')),\n CONSTRAINT imz_crm_opportunities_check_04 CHECK (probability BETWEEN 0 AND 100),\n CONSTRAINT imz_crm_opportunities_check_05 CHECK ((amount IS NULL)=(currency IS NULL)),\n CONSTRAINT imz_crm_opportunities_check_06 CHECK (amount IS NULL OR amount >= 0),\n CONSTRAINT imz_crm_opportunities_check_07 CHECK (currency IS NULL OR currency ~ '^[A-Z]{3}$')\n);" + }, + { + "name": "activities", + "domain": "CRM", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "investor_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "investor_id uuid CONSTRAINT imz_activities_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "investor id" + }, + { + "name": "opportunity_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "opportunity_id uuid CONSTRAINT imz_activities_opportunity_id_fk REFERENCES public.crm_opportunities(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "opportunity id" + }, + { + "name": "actor_user_id", + "type": "bigint", + "nullable": true, + "default": null, + "definition": "actor_user_id bigint CONSTRAINT imz_activities_actor_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "actor user id" + }, + { + "name": "activity_type", + "type": "text", + "nullable": false, + "default": null, + "definition": "activity_type text NOT NULL", + "description": "activity type" + }, + { + "name": "occurred_at", + "type": "timestamptz", + "nullable": false, + "default": null, + "definition": "occurred_at timestamptz NOT NULL", + "description": "occurred at" + }, + { + "name": "summary", + "type": "text", + "nullable": false, + "default": null, + "definition": "summary text NOT NULL", + "description": "summary" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "purpose": "Registar interacções CRM", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "activity_type IN ('call','meeting','email','note')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.activities (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n investor_id uuid CONSTRAINT imz_activities_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE,\n opportunity_id uuid CONSTRAINT imz_activities_opportunity_id_fk REFERENCES public.crm_opportunities(id) ON DELETE SET NULL ON UPDATE CASCADE,\n actor_user_id bigint CONSTRAINT imz_activities_actor_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE,\n activity_type text NOT NULL,\n occurred_at timestamptz NOT NULL,\n summary text NOT NULL,\n CONSTRAINT imz_activities_pk PRIMARY KEY (id),\n CONSTRAINT imz_activities_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_activities_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_activities_check_03 CHECK (activity_type IN ('call','meeting','email','note'))\n);" + }, + { + "name": "tasks", + "domain": "CRM", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "assignee_user_id", + "type": "bigint", + "nullable": true, + "default": null, + "definition": "assignee_user_id bigint CONSTRAINT imz_tasks_assignee_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "assignee user id" + }, + { + "name": "opportunity_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "opportunity_id uuid CONSTRAINT imz_tasks_opportunity_id_fk REFERENCES public.crm_opportunities(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "opportunity id" + }, + { + "name": "title", + "type": "text", + "nullable": false, + "default": null, + "definition": "title text NOT NULL", + "description": "title" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + }, + { + "name": "due_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "due_at timestamptz", + "description": "due at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "purpose": "Acompanhar tarefas e prazos CRM", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "status IN ('open','in_progress','done','cancelled')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.tasks (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n assignee_user_id bigint CONSTRAINT imz_tasks_assignee_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE,\n opportunity_id uuid CONSTRAINT imz_tasks_opportunity_id_fk REFERENCES public.crm_opportunities(id) ON DELETE CASCADE ON UPDATE CASCADE,\n title text NOT NULL,\n status text NOT NULL,\n due_at timestamptz,\n CONSTRAINT imz_tasks_pk PRIMARY KEY (id),\n CONSTRAINT imz_tasks_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_tasks_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_tasks_check_03 CHECK (status IN ('open','in_progress','done','cancelled'))\n);" + }, + { + "name": "investor_journeys", + "domain": "Jornada e aftercare", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "investor_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "investor_id uuid NOT NULL CONSTRAINT imz_investor_journeys_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "investor id" + }, + { + "name": "opportunity_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "opportunity_id uuid CONSTRAINT imz_investor_journeys_opportunity_id_fk REFERENCES public.crm_opportunities(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "opportunity id" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + }, + { + "name": "started_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "started_at timestamptz NOT NULL DEFAULT now()", + "description": "started at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "purpose": "Acompanhar jornadas dos investidores", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "status IN ('started','active','completed','cancelled')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.investor_journeys (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n investor_id uuid NOT NULL CONSTRAINT imz_investor_journeys_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE,\n opportunity_id uuid CONSTRAINT imz_investor_journeys_opportunity_id_fk REFERENCES public.crm_opportunities(id) ON DELETE SET NULL ON UPDATE CASCADE,\n status text NOT NULL,\n started_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT imz_investor_journeys_pk PRIMARY KEY (id),\n CONSTRAINT imz_investor_journeys_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_investor_journeys_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_investor_journeys_check_03 CHECK (status IN ('started','active','completed','cancelled'))\n);" + }, + { + "name": "journey_steps", + "domain": "Jornada e aftercare", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "journey_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "journey_id uuid NOT NULL CONSTRAINT imz_journey_steps_journey_id_fk REFERENCES public.investor_journeys(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "journey id" + }, + { + "name": "step_code", + "type": "text", + "nullable": false, + "default": null, + "definition": "step_code text NOT NULL", + "description": "step code" + }, + { + "name": "sequence_no", + "type": "integer", + "nullable": false, + "default": null, + "definition": "sequence_no integer NOT NULL", + "description": "sequence no" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "purpose": "Ordenar etapas de uma jornada", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "sequence_no>0", + "status IN ('pending','active','completed','blocked')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.journey_steps (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n journey_id uuid NOT NULL CONSTRAINT imz_journey_steps_journey_id_fk REFERENCES public.investor_journeys(id) ON DELETE CASCADE ON UPDATE CASCADE,\n step_code text NOT NULL,\n sequence_no integer NOT NULL,\n status text NOT NULL,\n CONSTRAINT imz_journey_steps_pk PRIMARY KEY (id),\n CONSTRAINT imz_journey_steps_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_journey_steps_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_journey_steps_check_03 CHECK (sequence_no>0),\n CONSTRAINT imz_journey_steps_check_04 CHECK (status IN ('pending','active','completed','blocked'))\n);" + }, + { + "name": "submissions", + "domain": "Jornada e aftercare", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "journey_step_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "journey_step_id uuid NOT NULL CONSTRAINT imz_submissions_journey_step_id_fk REFERENCES public.journey_steps(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "journey step id" + }, + { + "name": "submitted_by", + "type": "bigint", + "nullable": true, + "default": null, + "definition": "submitted_by bigint CONSTRAINT imz_submissions_submitted_by_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "submitted by" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + }, + { + "name": "payload", + "type": "jsonb", + "nullable": false, + "default": "'{}'::jsonb", + "definition": "payload jsonb NOT NULL DEFAULT '{}'::jsonb", + "description": "payload" + }, + { + "name": "submitted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "submitted_at timestamptz", + "description": "submitted at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "purpose": "Registar submissões nas etapas", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "status IN ('draft','submitted','accepted','rejected')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.submissions (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n journey_step_id uuid NOT NULL CONSTRAINT imz_submissions_journey_step_id_fk REFERENCES public.journey_steps(id) ON DELETE CASCADE ON UPDATE CASCADE,\n submitted_by bigint CONSTRAINT imz_submissions_submitted_by_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE,\n status text NOT NULL,\n payload jsonb NOT NULL DEFAULT '{}'::jsonb,\n submitted_at timestamptz,\n CONSTRAINT imz_submissions_pk PRIMARY KEY (id),\n CONSTRAINT imz_submissions_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_submissions_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_submissions_check_03 CHECK (status IN ('draft','submitted','accepted','rejected'))\n);" + }, + { + "name": "aftercare_plans", + "domain": "Jornada e aftercare", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "investor_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "investor_id uuid NOT NULL CONSTRAINT imz_aftercare_plans_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "investor id" + }, + { + "name": "project_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "project_id uuid CONSTRAINT imz_aftercare_plans_project_id_fk REFERENCES public.projects(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "project id" + }, + { + "name": "owner_user_id", + "type": "bigint", + "nullable": true, + "default": null, + "definition": "owner_user_id bigint CONSTRAINT imz_aftercare_plans_owner_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "owner user id" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + }, + { + "name": "review_date", + "type": "date", + "nullable": true, + "default": null, + "definition": "review_date date", + "description": "review date" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "purpose": "Acompanhar planos de pós-investimento", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "status IN ('draft','active','closed')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.aftercare_plans (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n investor_id uuid NOT NULL CONSTRAINT imz_aftercare_plans_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE,\n project_id uuid CONSTRAINT imz_aftercare_plans_project_id_fk REFERENCES public.projects(id) ON DELETE SET NULL ON UPDATE CASCADE,\n owner_user_id bigint CONSTRAINT imz_aftercare_plans_owner_user_id_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE,\n status text NOT NULL,\n review_date date,\n CONSTRAINT imz_aftercare_plans_pk PRIMARY KEY (id),\n CONSTRAINT imz_aftercare_plans_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_aftercare_plans_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_aftercare_plans_check_03 CHECK (status IN ('draft','active','closed'))\n);" + }, + { + "name": "constraints", + "domain": "Jornada e aftercare", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "aftercare_plan_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "aftercare_plan_id uuid NOT NULL CONSTRAINT imz_constraints_aftercare_plan_id_fk REFERENCES public.aftercare_plans(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "aftercare plan id" + }, + { + "name": "category", + "type": "text", + "nullable": false, + "default": null, + "definition": "category text NOT NULL", + "description": "category" + }, + { + "name": "description", + "type": "text", + "nullable": false, + "default": null, + "definition": "description text NOT NULL", + "description": "description" + }, + { + "name": "severity", + "type": "text", + "nullable": false, + "default": null, + "definition": "severity text NOT NULL", + "description": "severity" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "purpose": "Registar constrangimentos de pós-investimento", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "severity IN ('low','medium','high','critical')", + "status IN ('open','mitigating','resolved')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.constraints (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n aftercare_plan_id uuid NOT NULL CONSTRAINT imz_constraints_aftercare_plan_id_fk REFERENCES public.aftercare_plans(id) ON DELETE CASCADE ON UPDATE CASCADE,\n category text NOT NULL,\n description text NOT NULL,\n severity text NOT NULL,\n status text NOT NULL,\n CONSTRAINT imz_constraints_pk PRIMARY KEY (id),\n CONSTRAINT imz_constraints_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_constraints_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_constraints_check_03 CHECK (severity IN ('low','medium','high','critical')),\n CONSTRAINT imz_constraints_check_04 CHECK (status IN ('open','mitigating','resolved'))\n);" + }, + { + "name": "escalations", + "domain": "Jornada e aftercare", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "constraint_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "constraint_id uuid NOT NULL CONSTRAINT imz_escalations_constraint_id_fk REFERENCES public.constraints(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "constraint id" + }, + { + "name": "escalated_by", + "type": "bigint", + "nullable": true, + "default": null, + "definition": "escalated_by bigint CONSTRAINT imz_escalations_escalated_by_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "escalated by" + }, + { + "name": "level", + "type": "text", + "nullable": false, + "default": null, + "definition": "level text NOT NULL", + "description": "level" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + }, + { + "name": "escalated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "escalated_at timestamptz NOT NULL DEFAULT now()", + "description": "escalated at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "purpose": "Registar escalonamentos de constrangimentos", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "level IN ('operational','management','executive')", + "status IN ('open','acknowledged','closed')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.escalations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n constraint_id uuid NOT NULL CONSTRAINT imz_escalations_constraint_id_fk REFERENCES public.constraints(id) ON DELETE CASCADE ON UPDATE CASCADE,\n escalated_by bigint CONSTRAINT imz_escalations_escalated_by_fk REFERENCES public.users(id) ON DELETE SET NULL ON UPDATE CASCADE,\n level text NOT NULL,\n status text NOT NULL,\n escalated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT imz_escalations_pk PRIMARY KEY (id),\n CONSTRAINT imz_escalations_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_escalations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_escalations_check_03 CHECK (level IN ('operational','management','executive')),\n CONSTRAINT imz_escalations_check_04 CHECK (status IN ('open','acknowledged','closed'))\n);" + }, + { + "name": "skills", + "domain": "Educação", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "code", + "type": "text", + "nullable": false, + "default": null, + "definition": "code text NOT NULL", + "description": "code" + }, + { + "name": "name", + "type": "text", + "nullable": false, + "default": null, + "definition": "name text NOT NULL", + "description": "name" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php", + "purpose": "Catalogar competências", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.skills (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n code text NOT NULL,\n name text NOT NULL,\n CONSTRAINT imz_skills_pk PRIMARY KEY (id),\n CONSTRAINT imz_skills_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_skills_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted'))\n);" + }, + { + "name": "occupations", + "domain": "Educação", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "code", + "type": "text", + "nullable": false, + "default": null, + "definition": "code text NOT NULL", + "description": "code" + }, + { + "name": "name", + "type": "text", + "nullable": false, + "default": null, + "definition": "name text NOT NULL", + "description": "name" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php", + "purpose": "Catalogar ocupações", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.occupations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n code text NOT NULL,\n name text NOT NULL,\n CONSTRAINT imz_occupations_pk PRIMARY KEY (id),\n CONSTRAINT imz_occupations_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_occupations_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted'))\n);" + }, + { + "name": "training_needs", + "domain": "Educação", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "project_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "project_id uuid CONSTRAINT imz_training_needs_project_id_fk REFERENCES public.projects(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "project id" + }, + { + "name": "occupation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "occupation_id uuid CONSTRAINT imz_training_needs_occupation_id_fk REFERENCES public.occupations(id) ON DELETE RESTRICT ON UPDATE CASCADE", + "description": "occupation id" + }, + { + "name": "skill_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "skill_id uuid CONSTRAINT imz_training_needs_skill_id_fk REFERENCES public.skills(id) ON DELETE RESTRICT ON UPDATE CASCADE", + "description": "skill id" + }, + { + "name": "quantity", + "type": "integer", + "nullable": false, + "default": null, + "definition": "quantity integer NOT NULL", + "description": "quantity" + }, + { + "name": "priority", + "type": "text", + "nullable": false, + "default": null, + "definition": "priority text NOT NULL", + "description": "priority" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php", + "purpose": "Quantificar necessidades de formação", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "quantity>0", + "priority IN ('Must','Should','Could')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.training_needs (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n project_id uuid CONSTRAINT imz_training_needs_project_id_fk REFERENCES public.projects(id) ON DELETE SET NULL ON UPDATE CASCADE,\n occupation_id uuid CONSTRAINT imz_training_needs_occupation_id_fk REFERENCES public.occupations(id) ON DELETE RESTRICT ON UPDATE CASCADE,\n skill_id uuid CONSTRAINT imz_training_needs_skill_id_fk REFERENCES public.skills(id) ON DELETE RESTRICT ON UPDATE CASCADE,\n quantity integer NOT NULL,\n priority text NOT NULL,\n CONSTRAINT imz_training_needs_pk PRIMARY KEY (id),\n CONSTRAINT imz_training_needs_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_training_needs_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_training_needs_check_03 CHECK (quantity>0),\n CONSTRAINT imz_training_needs_check_04 CHECK (priority IN ('Must','Should','Could'))\n);" + }, + { + "name": "training_providers", + "domain": "Educação", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "organization_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "organization_id uuid CONSTRAINT imz_training_providers_organization_id_fk REFERENCES public.organizations(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "organization id" + }, + { + "name": "name", + "type": "text", + "nullable": false, + "default": null, + "definition": "name text NOT NULL", + "description": "name" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php", + "purpose": "Registar prestadores de formação", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "status IN ('candidate','approved','inactive')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.training_providers (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n organization_id uuid CONSTRAINT imz_training_providers_organization_id_fk REFERENCES public.organizations(id) ON DELETE SET NULL ON UPDATE CASCADE,\n name text NOT NULL,\n status text NOT NULL,\n CONSTRAINT imz_training_providers_pk PRIMARY KEY (id),\n CONSTRAINT imz_training_providers_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_training_providers_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_training_providers_check_03 CHECK (status IN ('candidate','approved','inactive'))\n);" + }, + { + "name": "courses", + "domain": "Educação", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "provider_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "provider_id uuid NOT NULL CONSTRAINT imz_courses_provider_id_fk REFERENCES public.training_providers(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "provider id" + }, + { + "name": "code", + "type": "text", + "nullable": false, + "default": null, + "definition": "code text NOT NULL", + "description": "code" + }, + { + "name": "title", + "type": "text", + "nullable": false, + "default": null, + "definition": "title text NOT NULL", + "description": "title" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php", + "purpose": "Catalogar cursos por prestador", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "status IN ('draft','published','retired')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.courses (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n provider_id uuid NOT NULL CONSTRAINT imz_courses_provider_id_fk REFERENCES public.training_providers(id) ON DELETE CASCADE ON UPDATE CASCADE,\n code text NOT NULL,\n title text NOT NULL,\n status text NOT NULL,\n CONSTRAINT imz_courses_pk PRIMARY KEY (id),\n CONSTRAINT imz_courses_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_courses_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_courses_check_03 CHECK (status IN ('draft','published','retired'))\n);" + }, + { + "name": "education_opportunities", + "domain": "Educação", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "course_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "course_id uuid NOT NULL CONSTRAINT imz_education_opportunities_course_id_fk REFERENCES public.courses(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "course id" + }, + { + "name": "geo_location_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "geo_location_id uuid CONSTRAINT imz_education_opportunities_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE SET NULL ON UPDATE CASCADE", + "description": "geo location id" + }, + { + "name": "start_date", + "type": "date", + "nullable": false, + "default": null, + "definition": "start_date date NOT NULL", + "description": "start date" + }, + { + "name": "end_date", + "type": "date", + "nullable": true, + "default": null, + "definition": "end_date date", + "description": "end date" + }, + { + "name": "capacity", + "type": "integer", + "nullable": true, + "default": null, + "definition": "capacity integer", + "description": "capacity" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php", + "purpose": "Publicar ofertas educativas com datas e capacidade", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "capacity>=0", + "status IN ('planned','open','closed','cancelled')", + "end_date IS NULL OR end_date>=start_date" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.education_opportunities (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n course_id uuid NOT NULL CONSTRAINT imz_education_opportunities_course_id_fk REFERENCES public.courses(id) ON DELETE CASCADE ON UPDATE CASCADE,\n geo_location_id uuid CONSTRAINT imz_education_opportunities_geo_location_id_fk REFERENCES public.geo_locations(id) ON DELETE SET NULL ON UPDATE CASCADE,\n start_date date NOT NULL,\n end_date date,\n capacity integer,\n status text NOT NULL,\n CONSTRAINT imz_education_opportunities_pk PRIMARY KEY (id),\n CONSTRAINT imz_education_opportunities_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_education_opportunities_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_education_opportunities_check_03 CHECK (capacity>=0),\n CONSTRAINT imz_education_opportunities_check_04 CHECK (status IN ('planned','open','closed','cancelled')),\n CONSTRAINT imz_education_opportunities_check_05 CHECK (end_date IS NULL OR end_date>=start_date)\n);" + }, + { + "name": "project_documents", + "domain": "Conteúdos — ligações tipadas", + "columns": [ + { + "name": "project_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "project_id uuid NOT NULL CONSTRAINT imz_project_documents_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "project id" + }, + { + "name": "document_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "document_id uuid NOT NULL CONSTRAINT imz_project_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE", + "description": "document id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + } + ], + "pk": [ + "project_id", + "document_id" + ], + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "purpose": "Associar documentos a projects com integridade referencial", + "source": "A.26", + "checks": [], + "soft_delete": false, + "sql": "CREATE TABLE public.project_documents (\n project_id uuid NOT NULL CONSTRAINT imz_project_documents_project_id_fk REFERENCES public.projects(id) ON DELETE CASCADE ON UPDATE CASCADE,\n document_id uuid NOT NULL CONSTRAINT imz_project_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE,\n created_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT imz_project_documents_pk PRIMARY KEY (project_id, document_id)\n);" + }, + { + "name": "investor_documents", + "domain": "Conteúdos — ligações tipadas", + "columns": [ + { + "name": "investor_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "investor_id uuid NOT NULL CONSTRAINT imz_investor_documents_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "investor id" + }, + { + "name": "document_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "document_id uuid NOT NULL CONSTRAINT imz_investor_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE", + "description": "document id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + } + ], + "pk": [ + "investor_id", + "document_id" + ], + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "purpose": "Associar documentos a investors com integridade referencial", + "source": "A.26", + "checks": [], + "soft_delete": false, + "sql": "CREATE TABLE public.investor_documents (\n investor_id uuid NOT NULL CONSTRAINT imz_investor_documents_investor_id_fk REFERENCES public.investors(id) ON DELETE CASCADE ON UPDATE CASCADE,\n document_id uuid NOT NULL CONSTRAINT imz_investor_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE,\n created_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT imz_investor_documents_pk PRIMARY KEY (investor_id, document_id)\n);" + }, + { + "name": "journey_documents", + "domain": "Conteúdos — ligações tipadas", + "columns": [ + { + "name": "journey_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "journey_id uuid NOT NULL CONSTRAINT imz_journey_documents_journey_id_fk REFERENCES public.investor_journeys(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "journey id" + }, + { + "name": "document_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "document_id uuid NOT NULL CONSTRAINT imz_journey_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE", + "description": "document id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + } + ], + "pk": [ + "journey_id", + "document_id" + ], + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "purpose": "Associar documentos a investor_journeys com integridade referencial", + "source": "A.26", + "checks": [], + "soft_delete": false, + "sql": "CREATE TABLE public.journey_documents (\n journey_id uuid NOT NULL CONSTRAINT imz_journey_documents_journey_id_fk REFERENCES public.investor_journeys(id) ON DELETE CASCADE ON UPDATE CASCADE,\n document_id uuid NOT NULL CONSTRAINT imz_journey_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE,\n created_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT imz_journey_documents_pk PRIMARY KEY (journey_id, document_id)\n);" + }, + { + "name": "aftercare_documents", + "domain": "Conteúdos — ligações tipadas", + "columns": [ + { + "name": "aftercare_plan_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "aftercare_plan_id uuid NOT NULL CONSTRAINT imz_aftercare_documents_aftercare_plan_id_fk REFERENCES public.aftercare_plans(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "aftercare plan id" + }, + { + "name": "document_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "document_id uuid NOT NULL CONSTRAINT imz_aftercare_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE", + "description": "document id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + } + ], + "pk": [ + "aftercare_plan_id", + "document_id" + ], + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "purpose": "Associar documentos a aftercare_plans com integridade referencial", + "source": "A.26", + "checks": [], + "soft_delete": false, + "sql": "CREATE TABLE public.aftercare_documents (\n aftercare_plan_id uuid NOT NULL CONSTRAINT imz_aftercare_documents_aftercare_plan_id_fk REFERENCES public.aftercare_plans(id) ON DELETE CASCADE ON UPDATE CASCADE,\n document_id uuid NOT NULL CONSTRAINT imz_aftercare_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE,\n created_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT imz_aftercare_documents_pk PRIMARY KEY (aftercare_plan_id, document_id)\n);" + }, + { + "name": "education_documents", + "domain": "Conteúdos — ligações tipadas", + "columns": [ + { + "name": "education_opportunity_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "education_opportunity_id uuid NOT NULL CONSTRAINT imz_education_documents_education_opportunity_id_fk REFERENCES public.education_opportunities(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "education opportunity id" + }, + { + "name": "document_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "document_id uuid NOT NULL CONSTRAINT imz_education_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE", + "description": "document id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + } + ], + "pk": [ + "education_opportunity_id", + "document_id" + ], + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "purpose": "Associar documentos a education_opportunities com integridade referencial", + "source": "A.26", + "checks": [], + "soft_delete": false, + "sql": "CREATE TABLE public.education_documents (\n education_opportunity_id uuid NOT NULL CONSTRAINT imz_education_documents_education_opportunity_id_fk REFERENCES public.education_opportunities(id) ON DELETE CASCADE ON UPDATE CASCADE,\n document_id uuid NOT NULL CONSTRAINT imz_education_documents_document_id_fk REFERENCES public.documents(id) ON DELETE RESTRICT ON UPDATE CASCADE,\n created_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT imz_education_documents_pk PRIMARY KEY (education_opportunity_id, document_id)\n);" + }, + { + "name": "audit_events", + "domain": "Governação e auditoria", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "actor_user_id", + "type": "bigint", + "nullable": true, + "default": null, + "definition": "actor_user_id bigint CONSTRAINT imz_audit_events_actor_user_id_fk REFERENCES public.users(id) ON DELETE RESTRICT ON UPDATE RESTRICT", + "description": "actor user id" + }, + { + "name": "entity_type", + "type": "text", + "nullable": false, + "default": null, + "definition": "entity_type text NOT NULL", + "description": "entity type" + }, + { + "name": "entity_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "entity_id uuid", + "description": "entity id" + }, + { + "name": "action", + "type": "text", + "nullable": false, + "default": null, + "definition": "action text NOT NULL", + "description": "action" + }, + { + "name": "occurred_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "occurred_at timestamptz NOT NULL DEFAULT now()", + "description": "occurred at" + }, + { + "name": "before_data", + "type": "jsonb", + "nullable": true, + "default": null, + "definition": "before_data jsonb", + "description": "before data" + }, + { + "name": "after_data", + "type": "jsonb", + "nullable": true, + "default": null, + "definition": "after_data jsonb", + "description": "after data" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_010000_investmoz_governance_ai.php", + "purpose": "Preservar eventos de auditoria sem actualização, eliminação ou truncagem", + "source": "A.26", + "checks": [ + "data_classification IN ('public','internal','confidential','restricted')" + ], + "soft_delete": false, + "sql": "CREATE TABLE public.audit_events (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n actor_user_id bigint CONSTRAINT imz_audit_events_actor_user_id_fk REFERENCES public.users(id) ON DELETE RESTRICT ON UPDATE RESTRICT,\n entity_type text NOT NULL,\n entity_id uuid,\n action text NOT NULL,\n occurred_at timestamptz NOT NULL DEFAULT now(),\n before_data jsonb,\n after_data jsonb,\n CONSTRAINT imz_audit_events_pk PRIMARY KEY (id),\n CONSTRAINT imz_audit_events_check_01 CHECK (data_classification IN ('public','internal','confidential','restricted'))\n);" + }, + { + "name": "outbox_events", + "domain": "Integrações e eventos", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "aggregate_type", + "type": "text", + "nullable": false, + "default": null, + "definition": "aggregate_type text NOT NULL", + "description": "aggregate type" + }, + { + "name": "aggregate_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "aggregate_id uuid NOT NULL", + "description": "aggregate id" + }, + { + "name": "event_type", + "type": "text", + "nullable": false, + "default": null, + "definition": "event_type text NOT NULL", + "description": "event type" + }, + { + "name": "payload", + "type": "jsonb", + "nullable": false, + "default": null, + "definition": "payload jsonb NOT NULL", + "description": "payload" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + }, + { + "name": "available_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "available_at timestamptz NOT NULL DEFAULT now()", + "description": "available at" + }, + { + "name": "published_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "published_at timestamptz", + "description": "published at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php", + "purpose": "Registar eventos de saída e estado de entrega", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "status IN ('pending','published','failed')" + ], + "soft_delete": false, + "sql": "CREATE TABLE public.outbox_events (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n aggregate_type text NOT NULL,\n aggregate_id uuid NOT NULL,\n event_type text NOT NULL,\n payload jsonb NOT NULL,\n status text NOT NULL,\n available_at timestamptz NOT NULL DEFAULT now(),\n published_at timestamptz,\n CONSTRAINT imz_outbox_events_pk PRIMARY KEY (id),\n CONSTRAINT imz_outbox_events_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_outbox_events_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_outbox_events_check_03 CHECK (status IN ('pending','published','failed'))\n);" + }, + { + "name": "inbox_events", + "domain": "Integrações e eventos", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "message_id", + "type": "text", + "nullable": false, + "default": null, + "definition": "message_id text NOT NULL", + "description": "message id" + }, + { + "name": "event_type", + "type": "text", + "nullable": false, + "default": null, + "definition": "event_type text NOT NULL", + "description": "event type" + }, + { + "name": "payload", + "type": "jsonb", + "nullable": false, + "default": null, + "definition": "payload jsonb NOT NULL", + "description": "payload" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + }, + { + "name": "received_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "received_at timestamptz NOT NULL DEFAULT now()", + "description": "received at" + }, + { + "name": "processed_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "processed_at timestamptz", + "description": "processed at" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php", + "purpose": "Deduplicar mensagens de entrada e acompanhar processamento", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "status IN ('received','processed','failed')" + ], + "soft_delete": false, + "sql": "CREATE TABLE public.inbox_events (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n message_id text NOT NULL,\n event_type text NOT NULL,\n payload jsonb NOT NULL,\n status text NOT NULL,\n received_at timestamptz NOT NULL DEFAULT now(),\n processed_at timestamptz,\n CONSTRAINT imz_inbox_events_pk PRIMARY KEY (id),\n CONSTRAINT imz_inbox_events_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_inbox_events_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_inbox_events_check_03 CHECK (status IN ('received','processed','failed'))\n);" + }, + { + "name": "sync_runs", + "domain": "Integrações e eventos", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "integration_name", + "type": "text", + "nullable": false, + "default": null, + "definition": "integration_name text NOT NULL", + "description": "integration name" + }, + { + "name": "direction", + "type": "text", + "nullable": false, + "default": null, + "definition": "direction text NOT NULL", + "description": "direction" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + }, + { + "name": "started_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "started_at timestamptz NOT NULL DEFAULT now()", + "description": "started at" + }, + { + "name": "ended_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "ended_at timestamptz", + "description": "ended at" + }, + { + "name": "totals", + "type": "jsonb", + "nullable": false, + "default": "'{}'::jsonb", + "definition": "totals jsonb NOT NULL DEFAULT '{}'::jsonb", + "description": "totals" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php", + "purpose": "Acompanhar execuções de integração", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "direction IN ('inbound','outbound')", + "status IN ('running','succeeded','failed','cancelled')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.sync_runs (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n integration_name text NOT NULL,\n direction text NOT NULL,\n status text NOT NULL,\n started_at timestamptz NOT NULL DEFAULT now(),\n ended_at timestamptz,\n totals jsonb NOT NULL DEFAULT '{}'::jsonb,\n CONSTRAINT imz_sync_runs_pk PRIMARY KEY (id),\n CONSTRAINT imz_sync_runs_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_sync_runs_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_sync_runs_check_03 CHECK (direction IN ('inbound','outbound')),\n CONSTRAINT imz_sync_runs_check_04 CHECK (status IN ('running','succeeded','failed','cancelled'))\n);" + }, + { + "name": "reconciliation_issues", + "domain": "Integrações e eventos", + "columns": [ + { + "name": "id", + "type": "uuid", + "nullable": false, + "default": "gen_random_uuid()", + "definition": "id uuid DEFAULT gen_random_uuid() NOT NULL", + "description": "id" + }, + { + "name": "source_system", + "type": "text", + "nullable": false, + "default": "'investmoz'", + "definition": "source_system text NOT NULL DEFAULT 'investmoz'", + "description": "source system" + }, + { + "name": "external_id", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id text", + "description": "external id" + }, + { + "name": "version_no", + "type": "integer", + "nullable": false, + "default": "1", + "definition": "version_no integer NOT NULL DEFAULT 1", + "description": "version no" + }, + { + "name": "data_classification", + "type": "text", + "nullable": false, + "default": "'internal'", + "definition": "data_classification text NOT NULL DEFAULT 'internal'", + "description": "data classification" + }, + { + "name": "correlation_id", + "type": "uuid", + "nullable": true, + "default": null, + "definition": "correlation_id uuid", + "description": "correlation id" + }, + { + "name": "created_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "created_at timestamptz NOT NULL DEFAULT now()", + "description": "created at" + }, + { + "name": "updated_at", + "type": "timestamptz", + "nullable": false, + "default": "now()", + "definition": "updated_at timestamptz NOT NULL DEFAULT now()", + "description": "updated at" + }, + { + "name": "deleted_at", + "type": "timestamptz", + "nullable": true, + "default": null, + "definition": "deleted_at timestamptz", + "description": "deleted at" + }, + { + "name": "sync_run_id", + "type": "uuid", + "nullable": false, + "default": null, + "definition": "sync_run_id uuid NOT NULL CONSTRAINT imz_reconciliation_issues_sync_run_id_fk REFERENCES public.sync_runs(id) ON DELETE CASCADE ON UPDATE CASCADE", + "description": "sync run id" + }, + { + "name": "entity_type", + "type": "text", + "nullable": false, + "default": null, + "definition": "entity_type text NOT NULL", + "description": "entity type" + }, + { + "name": "external_id_ref", + "type": "text", + "nullable": true, + "default": null, + "definition": "external_id_ref text", + "description": "external id ref" + }, + { + "name": "issue_type", + "type": "text", + "nullable": false, + "default": null, + "definition": "issue_type text NOT NULL", + "description": "issue type" + }, + { + "name": "severity", + "type": "text", + "nullable": false, + "default": null, + "definition": "severity text NOT NULL", + "description": "severity" + }, + { + "name": "status", + "type": "text", + "nullable": false, + "default": null, + "definition": "status text NOT NULL", + "description": "status" + }, + { + "name": "details", + "type": "jsonb", + "nullable": false, + "default": "'{}'::jsonb", + "definition": "details jsonb NOT NULL DEFAULT '{}'::jsonb", + "description": "details" + } + ], + "pk": [ + "id" + ], + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php", + "purpose": "Registar divergências de reconciliação", + "source": "A.26", + "checks": [ + "version_no > 0", + "data_classification IN ('public','internal','confidential','restricted')", + "issue_type IN ('missing','duplicate','orphan','checksum','geometry','value')", + "severity IN ('warning','error')", + "status IN ('open','accepted','resolved')" + ], + "soft_delete": true, + "sql": "CREATE TABLE public.reconciliation_issues (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n source_system text NOT NULL DEFAULT 'investmoz',\n external_id text,\n version_no integer NOT NULL DEFAULT 1,\n data_classification text NOT NULL DEFAULT 'internal',\n correlation_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n deleted_at timestamptz,\n sync_run_id uuid NOT NULL CONSTRAINT imz_reconciliation_issues_sync_run_id_fk REFERENCES public.sync_runs(id) ON DELETE CASCADE ON UPDATE CASCADE,\n entity_type text NOT NULL,\n external_id_ref text,\n issue_type text NOT NULL,\n severity text NOT NULL,\n status text NOT NULL,\n details jsonb NOT NULL DEFAULT '{}'::jsonb,\n CONSTRAINT imz_reconciliation_issues_pk PRIMARY KEY (id),\n CONSTRAINT imz_reconciliation_issues_check_01 CHECK (version_no > 0),\n CONSTRAINT imz_reconciliation_issues_check_02 CHECK (data_classification IN ('public','internal','confidential','restricted')),\n CONSTRAINT imz_reconciliation_issues_check_03 CHECK (issue_type IN ('missing','duplicate','orphan','checksum','geometry','value')),\n CONSTRAINT imz_reconciliation_issues_check_04 CHECK (severity IN ('warning','error')),\n CONSTRAINT imz_reconciliation_issues_check_05 CHECK (status IN ('open','accepted','resolved'))\n);" + } + ], + "relationships": [ + { + "table": "memberships", + "column": "user_id", + "target": "users", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_memberships_user_id_fk", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php", + "logical": false + }, + { + "table": "memberships", + "column": "organization_id", + "target": "organizations", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_memberships_organization_id_fk", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php", + "logical": false + }, + { + "table": "consents", + "column": "user_id", + "target": "users", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_consents_user_id_fk", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php", + "logical": false + }, + { + "table": "geo_locations", + "column": "parent_id", + "target": "geo_locations", + "target_column": "id", + "nullable": true, + "on_delete": "RESTRICT", + "on_update": "CASCADE", + "name": "imz_geo_locations_parent_id_fk", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php", + "logical": false + }, + { + "table": "geo_geometries", + "column": "geo_location_id", + "target": "geo_locations", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_geo_geometries_geo_location_id_fk", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php", + "logical": false + }, + { + "table": "project_locations", + "column": "project_id", + "target": "projects", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_project_locations_project_id_fk", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php", + "logical": false + }, + { + "table": "project_locations", + "column": "geo_location_id", + "target": "geo_locations", + "target_column": "id", + "nullable": false, + "on_delete": "RESTRICT", + "on_update": "CASCADE", + "name": "imz_project_locations_geo_location_id_fk", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php", + "logical": false + }, + { + "table": "value_chains", + "column": "cluster_id", + "target": "economic_clusters", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_value_chains_cluster_id_fk", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "logical": false + }, + { + "table": "value_chain_links", + "column": "from_value_chain_id", + "target": "value_chains", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_value_chain_links_from_value_chain_id_fk", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "logical": false + }, + { + "table": "value_chain_links", + "column": "to_value_chain_id", + "target": "value_chains", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_value_chain_links_to_value_chain_id_fk", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "logical": false + }, + { + "table": "cluster_projects", + "column": "cluster_id", + "target": "economic_clusters", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_cluster_projects_cluster_id_fk", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "logical": false + }, + { + "table": "cluster_projects", + "column": "project_id", + "target": "projects", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_cluster_projects_project_id_fk", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "logical": false + }, + { + "table": "anchor_project_links", + "column": "anchor_project_id", + "target": "projects", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_anchor_project_links_anchor_project_id_fk", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "logical": false + }, + { + "table": "anchor_project_links", + "column": "cluster_id", + "target": "economic_clusters", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_anchor_project_links_cluster_id_fk", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "logical": false + }, + { + "table": "complementary_project_links", + "column": "anchor_project_id", + "target": "projects", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_complementary_project_links_anchor_project_id_fk", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "logical": false + }, + { + "table": "complementary_project_links", + "column": "complementary_project_id", + "target": "projects", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_complementary_project_links_complementary_project_id_fk", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php", + "logical": false + }, + { + "table": "corridor_locations", + "column": "corridor_id", + "target": "development_corridors", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_corridor_locations_corridor_id_fk", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php", + "logical": false + }, + { + "table": "corridor_locations", + "column": "geo_location_id", + "target": "geo_locations", + "target_column": "id", + "nullable": false, + "on_delete": "RESTRICT", + "on_update": "CASCADE", + "name": "imz_corridor_locations_geo_location_id_fk", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php", + "logical": false + }, + { + "table": "corridor_projects", + "column": "corridor_id", + "target": "development_corridors", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_corridor_projects_corridor_id_fk", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php", + "logical": false + }, + { + "table": "corridor_projects", + "column": "project_id", + "target": "projects", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_corridor_projects_project_id_fk", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php", + "logical": false + }, + { + "table": "corridor_clusters", + "column": "corridor_id", + "target": "development_corridors", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_corridor_clusters_corridor_id_fk", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php", + "logical": false + }, + { + "table": "corridor_clusters", + "column": "cluster_id", + "target": "economic_clusters", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_corridor_clusters_cluster_id_fk", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php", + "logical": false + }, + { + "table": "investors", + "column": "organization_id", + "target": "organizations", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_investors_organization_id_fk", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "logical": false + }, + { + "table": "investor_contacts", + "column": "investor_id", + "target": "investors", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_investor_contacts_investor_id_fk", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "logical": false + }, + { + "table": "investor_contacts", + "column": "contact_id", + "target": "contacts", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_investor_contacts_contact_id_fk", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "logical": false + }, + { + "table": "leads", + "column": "investor_id", + "target": "investors", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_leads_investor_id_fk", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "logical": false + }, + { + "table": "leads", + "column": "owner_user_id", + "target": "users", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_leads_owner_user_id_fk", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "logical": false + }, + { + "table": "crm_opportunities", + "column": "lead_id", + "target": "leads", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_crm_opportunities_lead_id_fk", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "logical": false + }, + { + "table": "crm_opportunities", + "column": "project_id", + "target": "projects", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_crm_opportunities_project_id_fk", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "logical": false + }, + { + "table": "crm_opportunities", + "column": "owner_user_id", + "target": "users", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_crm_opportunities_owner_user_id_fk", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "logical": false + }, + { + "table": "activities", + "column": "investor_id", + "target": "investors", + "target_column": "id", + "nullable": true, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_activities_investor_id_fk", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "logical": false + }, + { + "table": "activities", + "column": "opportunity_id", + "target": "crm_opportunities", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_activities_opportunity_id_fk", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "logical": false + }, + { + "table": "activities", + "column": "actor_user_id", + "target": "users", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_activities_actor_user_id_fk", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "logical": false + }, + { + "table": "tasks", + "column": "assignee_user_id", + "target": "users", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_tasks_assignee_user_id_fk", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "logical": false + }, + { + "table": "tasks", + "column": "opportunity_id", + "target": "crm_opportunities", + "target_column": "id", + "nullable": true, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_tasks_opportunity_id_fk", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php", + "logical": false + }, + { + "table": "investor_journeys", + "column": "investor_id", + "target": "investors", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_investor_journeys_investor_id_fk", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "logical": false + }, + { + "table": "investor_journeys", + "column": "opportunity_id", + "target": "crm_opportunities", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_investor_journeys_opportunity_id_fk", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "logical": false + }, + { + "table": "journey_steps", + "column": "journey_id", + "target": "investor_journeys", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_journey_steps_journey_id_fk", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "logical": false + }, + { + "table": "submissions", + "column": "journey_step_id", + "target": "journey_steps", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_submissions_journey_step_id_fk", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "logical": false + }, + { + "table": "submissions", + "column": "submitted_by", + "target": "users", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_submissions_submitted_by_fk", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "logical": false + }, + { + "table": "aftercare_plans", + "column": "investor_id", + "target": "investors", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_aftercare_plans_investor_id_fk", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "logical": false + }, + { + "table": "aftercare_plans", + "column": "project_id", + "target": "projects", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_aftercare_plans_project_id_fk", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "logical": false + }, + { + "table": "aftercare_plans", + "column": "owner_user_id", + "target": "users", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_aftercare_plans_owner_user_id_fk", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "logical": false + }, + { + "table": "constraints", + "column": "aftercare_plan_id", + "target": "aftercare_plans", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_constraints_aftercare_plan_id_fk", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "logical": false + }, + { + "table": "escalations", + "column": "constraint_id", + "target": "constraints", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_escalations_constraint_id_fk", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "logical": false + }, + { + "table": "escalations", + "column": "escalated_by", + "target": "users", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_escalations_escalated_by_fk", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php", + "logical": false + }, + { + "table": "training_needs", + "column": "project_id", + "target": "projects", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_training_needs_project_id_fk", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php", + "logical": false + }, + { + "table": "training_needs", + "column": "occupation_id", + "target": "occupations", + "target_column": "id", + "nullable": true, + "on_delete": "RESTRICT", + "on_update": "CASCADE", + "name": "imz_training_needs_occupation_id_fk", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php", + "logical": false + }, + { + "table": "training_needs", + "column": "skill_id", + "target": "skills", + "target_column": "id", + "nullable": true, + "on_delete": "RESTRICT", + "on_update": "CASCADE", + "name": "imz_training_needs_skill_id_fk", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php", + "logical": false + }, + { + "table": "training_providers", + "column": "organization_id", + "target": "organizations", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_training_providers_organization_id_fk", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php", + "logical": false + }, + { + "table": "courses", + "column": "provider_id", + "target": "training_providers", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_courses_provider_id_fk", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php", + "logical": false + }, + { + "table": "education_opportunities", + "column": "course_id", + "target": "courses", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_education_opportunities_course_id_fk", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php", + "logical": false + }, + { + "table": "education_opportunities", + "column": "geo_location_id", + "target": "geo_locations", + "target_column": "id", + "nullable": true, + "on_delete": "SET NULL", + "on_update": "CASCADE", + "name": "imz_education_opportunities_geo_location_id_fk", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php", + "logical": false + }, + { + "table": "project_documents", + "column": "project_id", + "target": "projects", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_project_documents_project_id_fk", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "logical": false + }, + { + "table": "project_documents", + "column": "document_id", + "target": "documents", + "target_column": "id", + "nullable": false, + "on_delete": "RESTRICT", + "on_update": "CASCADE", + "name": "imz_project_documents_document_id_fk", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "logical": false + }, + { + "table": "investor_documents", + "column": "investor_id", + "target": "investors", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_investor_documents_investor_id_fk", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "logical": false + }, + { + "table": "investor_documents", + "column": "document_id", + "target": "documents", + "target_column": "id", + "nullable": false, + "on_delete": "RESTRICT", + "on_update": "CASCADE", + "name": "imz_investor_documents_document_id_fk", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "logical": false + }, + { + "table": "journey_documents", + "column": "journey_id", + "target": "investor_journeys", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_journey_documents_journey_id_fk", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "logical": false + }, + { + "table": "journey_documents", + "column": "document_id", + "target": "documents", + "target_column": "id", + "nullable": false, + "on_delete": "RESTRICT", + "on_update": "CASCADE", + "name": "imz_journey_documents_document_id_fk", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "logical": false + }, + { + "table": "aftercare_documents", + "column": "aftercare_plan_id", + "target": "aftercare_plans", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_aftercare_documents_aftercare_plan_id_fk", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "logical": false + }, + { + "table": "aftercare_documents", + "column": "document_id", + "target": "documents", + "target_column": "id", + "nullable": false, + "on_delete": "RESTRICT", + "on_update": "CASCADE", + "name": "imz_aftercare_documents_document_id_fk", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "logical": false + }, + { + "table": "education_documents", + "column": "education_opportunity_id", + "target": "education_opportunities", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_education_documents_education_opportunity_id_fk", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "logical": false + }, + { + "table": "education_documents", + "column": "document_id", + "target": "documents", + "target_column": "id", + "nullable": false, + "on_delete": "RESTRICT", + "on_update": "CASCADE", + "name": "imz_education_documents_document_id_fk", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php", + "logical": false + }, + { + "table": "audit_events", + "column": "actor_user_id", + "target": "users", + "target_column": "id", + "nullable": true, + "on_delete": "RESTRICT", + "on_update": "RESTRICT", + "name": "imz_audit_events_actor_user_id_fk", + "migration": "database/migrations/2026_09_24_010000_investmoz_governance_ai.php", + "logical": false + }, + { + "table": "reconciliation_issues", + "column": "sync_run_id", + "target": "sync_runs", + "target_column": "id", + "nullable": false, + "on_delete": "CASCADE", + "on_update": "CASCADE", + "name": "imz_reconciliation_issues_sync_run_id_fk", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php", + "logical": false + } + ], + "constraints": [ + { + "name": "imz_organizations_pk", + "table": "organizations", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_organizations_check_01", + "table": "organizations", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_organizations_check_02", + "table": "organizations", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_organizations_check_03", + "table": "organizations", + "kind": "CHECK", + "expression": "organization_type IN ('public','private','ngo','education')", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_memberships_user_id_fk", + "table": "memberships", + "kind": "FOREIGN KEY", + "expression": "user_id -> users.id", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_memberships_organization_id_fk", + "table": "memberships", + "kind": "FOREIGN KEY", + "expression": "organization_id -> organizations.id", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_memberships_pk", + "table": "memberships", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_memberships_check_01", + "table": "memberships", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_memberships_check_02", + "table": "memberships", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_memberships_check_03", + "table": "memberships", + "kind": "CHECK", + "expression": "role IN ('admin','manager','officer','viewer')", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_consents_user_id_fk", + "table": "consents", + "kind": "FOREIGN KEY", + "expression": "user_id -> users.id", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_consents_pk", + "table": "consents", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_consents_check_01", + "table": "consents", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_consents_check_02", + "table": "consents", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_consents_check_03", + "table": "consents", + "kind": "CHECK", + "expression": "status IN ('granted','withdrawn','expired')", + "migration": "database/migrations/2026_09_24_001000_investmoz_identity.php" + }, + { + "name": "imz_documents_pk", + "table": "documents", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_002000_investmoz_content.php" + }, + { + "name": "imz_documents_check_01", + "table": "documents", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_002000_investmoz_content.php" + }, + { + "name": "imz_documents_check_02", + "table": "documents", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_002000_investmoz_content.php" + }, + { + "name": "imz_documents_check_03", + "table": "documents", + "kind": "CHECK", + "expression": "status IN ('active','quarantined','deleted')", + "migration": "database/migrations/2026_09_24_002000_investmoz_content.php" + }, + { + "name": "imz_documents_check_04", + "table": "documents", + "kind": "CHECK", + "expression": "sha256 ~ '^[0-9a-f]{64}$'", + "migration": "database/migrations/2026_09_24_002000_investmoz_content.php" + }, + { + "name": "imz_geo_locations_parent_id_fk", + "table": "geo_locations", + "kind": "FOREIGN KEY", + "expression": "parent_id -> geo_locations.id", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_locations_pk", + "table": "geo_locations", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_locations_check_01", + "table": "geo_locations", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_locations_check_02", + "table": "geo_locations", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_locations_check_03", + "table": "geo_locations", + "kind": "CHECK", + "expression": "location_type IN ('country','province','district','municipality','site')", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_geometries_geo_location_id_fk", + "table": "geo_geometries", + "kind": "FOREIGN KEY", + "expression": "geo_location_id -> geo_locations.id", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_geometries_pk", + "table": "geo_geometries", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_geometries_check_01", + "table": "geo_geometries", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_geometries_check_02", + "table": "geo_geometries", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_geometries_check_03", + "table": "geo_geometries", + "kind": "CHECK", + "expression": "geometry_type IN ('point','line','polygon','multipolygon')", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_geometries_check_04", + "table": "geo_geometries", + "kind": "CHECK", + "expression": "ST_SRID(geom) = 4326", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_geometries_check_05", + "table": "geo_geometries", + "kind": "CHECK", + "expression": "ST_NDims(geom) = 2", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_geometries_check_06", + "table": "geo_geometries", + "kind": "CHECK", + "expression": "NOT ST_IsEmpty(geom)", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_geometries_check_07", + "table": "geo_geometries", + "kind": "CHECK", + "expression": "ST_IsValid(geom)", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_geometries_check_08", + "table": "geo_geometries", + "kind": "CHECK", + "expression": "(geometry_type = 'point' AND GeometryType(geom) = 'POINT') OR (geometry_type = 'line' AND GeometryType(geom) = 'LINESTRING') OR (geometry_type = 'polygon' AND GeometryType(geom) = 'POLYGON') OR (geometry_type = 'multipolygon' AND GeometryType(geom) = 'MULTIPOLYGON')", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_geo_geometries_check_09", + "table": "geo_geometries", + "kind": "CHECK", + "expression": "ST_XMin(Box3D(geom)) >= -180 AND ST_XMax(Box3D(geom)) <= 180 AND ST_YMin(Box3D(geom)) >= -90 AND ST_YMax(Box3D(geom)) <= 90", + "migration": "database/migrations/2026_09_24_003000_investmoz_geography.php" + }, + { + "name": "imz_projects_pk", + "table": "projects", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php" + }, + { + "name": "imz_projects_check_01", + "table": "projects", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php" + }, + { + "name": "imz_projects_check_02", + "table": "projects", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php" + }, + { + "name": "imz_projects_check_03", + "table": "projects", + "kind": "CHECK", + "expression": "status IN ('draft','planned','active','suspended','completed','cancelled')", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php" + }, + { + "name": "imz_projects_check_04", + "table": "projects", + "kind": "CHECK", + "expression": "budget_amount IS NULL OR budget_amount >= 0", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php" + }, + { + "name": "imz_projects_check_05", + "table": "projects", + "kind": "CHECK", + "expression": "end_date IS NULL OR start_date IS NULL OR end_date >= start_date", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php" + }, + { + "name": "imz_projects_check_06", + "table": "projects", + "kind": "CHECK", + "expression": "(budget_amount IS NULL) = (budget_currency IS NULL)", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php" + }, + { + "name": "imz_projects_check_07", + "table": "projects", + "kind": "CHECK", + "expression": "budget_currency IS NULL OR budget_currency ~ '^[A-Z]{3}$'", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php" + }, + { + "name": "imz_project_locations_project_id_fk", + "table": "project_locations", + "kind": "FOREIGN KEY", + "expression": "project_id -> projects.id", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php" + }, + { + "name": "imz_project_locations_geo_location_id_fk", + "table": "project_locations", + "kind": "FOREIGN KEY", + "expression": "geo_location_id -> geo_locations.id", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php" + }, + { + "name": "imz_project_locations_pk", + "table": "project_locations", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php" + }, + { + "name": "imz_project_locations_check_01", + "table": "project_locations", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php" + }, + { + "name": "imz_project_locations_check_02", + "table": "project_locations", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php" + }, + { + "name": "imz_project_locations_check_03", + "table": "project_locations", + "kind": "CHECK", + "expression": "role IN ('primary','secondary','impact')", + "migration": "database/migrations/2026_09_24_004000_investmoz_projects.php" + }, + { + "name": "imz_economic_clusters_pk", + "table": "economic_clusters", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_economic_clusters_check_01", + "table": "economic_clusters", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_economic_clusters_check_02", + "table": "economic_clusters", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_economic_clusters_check_03", + "table": "economic_clusters", + "kind": "CHECK", + "expression": "status IN ('planned','active','inactive')", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_value_chains_cluster_id_fk", + "table": "value_chains", + "kind": "FOREIGN KEY", + "expression": "cluster_id -> economic_clusters.id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_value_chains_pk", + "table": "value_chains", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_value_chains_check_01", + "table": "value_chains", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_value_chains_check_02", + "table": "value_chains", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_value_chains_check_03", + "table": "value_chains", + "kind": "CHECK", + "expression": "stage IN ('input','production','processing','logistics','market')", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_value_chain_links_from_value_chain_id_fk", + "table": "value_chain_links", + "kind": "FOREIGN KEY", + "expression": "from_value_chain_id -> value_chains.id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_value_chain_links_to_value_chain_id_fk", + "table": "value_chain_links", + "kind": "FOREIGN KEY", + "expression": "to_value_chain_id -> value_chains.id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_value_chain_links_pk", + "table": "value_chain_links", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_value_chain_links_check_01", + "table": "value_chain_links", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_value_chain_links_check_02", + "table": "value_chain_links", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_value_chain_links_check_03", + "table": "value_chain_links", + "kind": "CHECK", + "expression": "link_type IN ('supplies','processes','distributes','supports')", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_value_chain_links_check_04", + "table": "value_chain_links", + "kind": "CHECK", + "expression": "from_value_chain_id <> to_value_chain_id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_cluster_projects_cluster_id_fk", + "table": "cluster_projects", + "kind": "FOREIGN KEY", + "expression": "cluster_id -> economic_clusters.id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_cluster_projects_project_id_fk", + "table": "cluster_projects", + "kind": "FOREIGN KEY", + "expression": "project_id -> projects.id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_cluster_projects_pk", + "table": "cluster_projects", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_cluster_projects_check_01", + "table": "cluster_projects", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_cluster_projects_check_02", + "table": "cluster_projects", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_cluster_projects_check_03", + "table": "cluster_projects", + "kind": "CHECK", + "expression": "role IN ('anchor','complementary','supporting')", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_anchor_project_links_anchor_project_id_fk", + "table": "anchor_project_links", + "kind": "FOREIGN KEY", + "expression": "anchor_project_id -> projects.id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_anchor_project_links_cluster_id_fk", + "table": "anchor_project_links", + "kind": "FOREIGN KEY", + "expression": "cluster_id -> economic_clusters.id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_anchor_project_links_pk", + "table": "anchor_project_links", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_anchor_project_links_check_01", + "table": "anchor_project_links", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_anchor_project_links_check_02", + "table": "anchor_project_links", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_complementary_project_links_anchor_project_id_fk", + "table": "complementary_project_links", + "kind": "FOREIGN KEY", + "expression": "anchor_project_id -> projects.id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_complementary_project_links_complementary_project_id_fk", + "table": "complementary_project_links", + "kind": "FOREIGN KEY", + "expression": "complementary_project_id -> projects.id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_complementary_project_links_pk", + "table": "complementary_project_links", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_complementary_project_links_check_01", + "table": "complementary_project_links", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_complementary_project_links_check_02", + "table": "complementary_project_links", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_complementary_project_links_check_03", + "table": "complementary_project_links", + "kind": "CHECK", + "expression": "relation_type IN ('dependency','enabler','supplier','market')", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_complementary_project_links_check_04", + "table": "complementary_project_links", + "kind": "CHECK", + "expression": "anchor_project_id<>complementary_project_id", + "migration": "database/migrations/2026_09_24_005000_investmoz_clusters.php" + }, + { + "name": "imz_development_corridors_pk", + "table": "development_corridors", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_development_corridors_check_01", + "table": "development_corridors", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_development_corridors_check_02", + "table": "development_corridors", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_development_corridors_check_03", + "table": "development_corridors", + "kind": "CHECK", + "expression": "status IN ('proposed','active','closed')", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_locations_corridor_id_fk", + "table": "corridor_locations", + "kind": "FOREIGN KEY", + "expression": "corridor_id -> development_corridors.id", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_locations_geo_location_id_fk", + "table": "corridor_locations", + "kind": "FOREIGN KEY", + "expression": "geo_location_id -> geo_locations.id", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_locations_pk", + "table": "corridor_locations", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_locations_check_01", + "table": "corridor_locations", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_locations_check_02", + "table": "corridor_locations", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_locations_check_03", + "table": "corridor_locations", + "kind": "CHECK", + "expression": "sequence_no>0", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_projects_corridor_id_fk", + "table": "corridor_projects", + "kind": "FOREIGN KEY", + "expression": "corridor_id -> development_corridors.id", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_projects_project_id_fk", + "table": "corridor_projects", + "kind": "FOREIGN KEY", + "expression": "project_id -> projects.id", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_projects_pk", + "table": "corridor_projects", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_projects_check_01", + "table": "corridor_projects", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_projects_check_02", + "table": "corridor_projects", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_clusters_corridor_id_fk", + "table": "corridor_clusters", + "kind": "FOREIGN KEY", + "expression": "corridor_id -> development_corridors.id", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_clusters_cluster_id_fk", + "table": "corridor_clusters", + "kind": "FOREIGN KEY", + "expression": "cluster_id -> economic_clusters.id", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_clusters_pk", + "table": "corridor_clusters", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_clusters_check_01", + "table": "corridor_clusters", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_corridor_clusters_check_02", + "table": "corridor_clusters", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_006000_investmoz_corridors.php" + }, + { + "name": "imz_investors_organization_id_fk", + "table": "investors", + "kind": "FOREIGN KEY", + "expression": "organization_id -> organizations.id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_investors_pk", + "table": "investors", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_investors_check_01", + "table": "investors", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_investors_check_02", + "table": "investors", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_investors_check_03", + "table": "investors", + "kind": "CHECK", + "expression": "investor_type IN ('domestic','foreign','institutional','individual')", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_investors_check_04", + "table": "investors", + "kind": "CHECK", + "expression": "status IN ('prospect','active','inactive')", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_contacts_pk", + "table": "contacts", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_contacts_check_01", + "table": "contacts", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_contacts_check_02", + "table": "contacts", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_investor_contacts_investor_id_fk", + "table": "investor_contacts", + "kind": "FOREIGN KEY", + "expression": "investor_id -> investors.id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_investor_contacts_contact_id_fk", + "table": "investor_contacts", + "kind": "FOREIGN KEY", + "expression": "contact_id -> contacts.id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_investor_contacts_pk", + "table": "investor_contacts", + "kind": "PRIMARY KEY", + "expression": "investor_id, contact_id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_leads_investor_id_fk", + "table": "leads", + "kind": "FOREIGN KEY", + "expression": "investor_id -> investors.id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_leads_owner_user_id_fk", + "table": "leads", + "kind": "FOREIGN KEY", + "expression": "owner_user_id -> users.id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_leads_pk", + "table": "leads", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_leads_check_01", + "table": "leads", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_leads_check_02", + "table": "leads", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_leads_check_03", + "table": "leads", + "kind": "CHECK", + "expression": "status IN ('new','qualified','disqualified','converted')", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_leads_check_04", + "table": "leads", + "kind": "CHECK", + "expression": "(estimated_amount IS NULL)=(currency IS NULL)", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_leads_check_05", + "table": "leads", + "kind": "CHECK", + "expression": "estimated_amount IS NULL OR estimated_amount >= 0", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_leads_check_06", + "table": "leads", + "kind": "CHECK", + "expression": "currency IS NULL OR currency ~ '^[A-Z]{3}$'", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_crm_opportunities_lead_id_fk", + "table": "crm_opportunities", + "kind": "FOREIGN KEY", + "expression": "lead_id -> leads.id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_crm_opportunities_project_id_fk", + "table": "crm_opportunities", + "kind": "FOREIGN KEY", + "expression": "project_id -> projects.id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_crm_opportunities_owner_user_id_fk", + "table": "crm_opportunities", + "kind": "FOREIGN KEY", + "expression": "owner_user_id -> users.id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_crm_opportunities_pk", + "table": "crm_opportunities", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_crm_opportunities_check_01", + "table": "crm_opportunities", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_crm_opportunities_check_02", + "table": "crm_opportunities", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_crm_opportunities_check_03", + "table": "crm_opportunities", + "kind": "CHECK", + "expression": "stage IN ('discovery','assessment','negotiation','approved','lost')", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_crm_opportunities_check_04", + "table": "crm_opportunities", + "kind": "CHECK", + "expression": "probability BETWEEN 0 AND 100", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_crm_opportunities_check_05", + "table": "crm_opportunities", + "kind": "CHECK", + "expression": "(amount IS NULL)=(currency IS NULL)", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_crm_opportunities_check_06", + "table": "crm_opportunities", + "kind": "CHECK", + "expression": "amount IS NULL OR amount >= 0", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_crm_opportunities_check_07", + "table": "crm_opportunities", + "kind": "CHECK", + "expression": "currency IS NULL OR currency ~ '^[A-Z]{3}$'", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_activities_investor_id_fk", + "table": "activities", + "kind": "FOREIGN KEY", + "expression": "investor_id -> investors.id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_activities_opportunity_id_fk", + "table": "activities", + "kind": "FOREIGN KEY", + "expression": "opportunity_id -> crm_opportunities.id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_activities_actor_user_id_fk", + "table": "activities", + "kind": "FOREIGN KEY", + "expression": "actor_user_id -> users.id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_activities_pk", + "table": "activities", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_activities_check_01", + "table": "activities", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_activities_check_02", + "table": "activities", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_activities_check_03", + "table": "activities", + "kind": "CHECK", + "expression": "activity_type IN ('call','meeting','email','note')", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_tasks_assignee_user_id_fk", + "table": "tasks", + "kind": "FOREIGN KEY", + "expression": "assignee_user_id -> users.id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_tasks_opportunity_id_fk", + "table": "tasks", + "kind": "FOREIGN KEY", + "expression": "opportunity_id -> crm_opportunities.id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_tasks_pk", + "table": "tasks", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_tasks_check_01", + "table": "tasks", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_tasks_check_02", + "table": "tasks", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_tasks_check_03", + "table": "tasks", + "kind": "CHECK", + "expression": "status IN ('open','in_progress','done','cancelled')", + "migration": "database/migrations/2026_09_24_007000_investmoz_crm.php" + }, + { + "name": "imz_investor_journeys_investor_id_fk", + "table": "investor_journeys", + "kind": "FOREIGN KEY", + "expression": "investor_id -> investors.id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_investor_journeys_opportunity_id_fk", + "table": "investor_journeys", + "kind": "FOREIGN KEY", + "expression": "opportunity_id -> crm_opportunities.id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_investor_journeys_pk", + "table": "investor_journeys", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_investor_journeys_check_01", + "table": "investor_journeys", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_investor_journeys_check_02", + "table": "investor_journeys", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_investor_journeys_check_03", + "table": "investor_journeys", + "kind": "CHECK", + "expression": "status IN ('started','active','completed','cancelled')", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_journey_steps_journey_id_fk", + "table": "journey_steps", + "kind": "FOREIGN KEY", + "expression": "journey_id -> investor_journeys.id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_journey_steps_pk", + "table": "journey_steps", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_journey_steps_check_01", + "table": "journey_steps", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_journey_steps_check_02", + "table": "journey_steps", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_journey_steps_check_03", + "table": "journey_steps", + "kind": "CHECK", + "expression": "sequence_no>0", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_journey_steps_check_04", + "table": "journey_steps", + "kind": "CHECK", + "expression": "status IN ('pending','active','completed','blocked')", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_submissions_journey_step_id_fk", + "table": "submissions", + "kind": "FOREIGN KEY", + "expression": "journey_step_id -> journey_steps.id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_submissions_submitted_by_fk", + "table": "submissions", + "kind": "FOREIGN KEY", + "expression": "submitted_by -> users.id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_submissions_pk", + "table": "submissions", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_submissions_check_01", + "table": "submissions", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_submissions_check_02", + "table": "submissions", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_submissions_check_03", + "table": "submissions", + "kind": "CHECK", + "expression": "status IN ('draft','submitted','accepted','rejected')", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_aftercare_plans_investor_id_fk", + "table": "aftercare_plans", + "kind": "FOREIGN KEY", + "expression": "investor_id -> investors.id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_aftercare_plans_project_id_fk", + "table": "aftercare_plans", + "kind": "FOREIGN KEY", + "expression": "project_id -> projects.id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_aftercare_plans_owner_user_id_fk", + "table": "aftercare_plans", + "kind": "FOREIGN KEY", + "expression": "owner_user_id -> users.id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_aftercare_plans_pk", + "table": "aftercare_plans", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_aftercare_plans_check_01", + "table": "aftercare_plans", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_aftercare_plans_check_02", + "table": "aftercare_plans", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_aftercare_plans_check_03", + "table": "aftercare_plans", + "kind": "CHECK", + "expression": "status IN ('draft','active','closed')", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_constraints_aftercare_plan_id_fk", + "table": "constraints", + "kind": "FOREIGN KEY", + "expression": "aftercare_plan_id -> aftercare_plans.id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_constraints_pk", + "table": "constraints", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_constraints_check_01", + "table": "constraints", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_constraints_check_02", + "table": "constraints", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_constraints_check_03", + "table": "constraints", + "kind": "CHECK", + "expression": "severity IN ('low','medium','high','critical')", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_constraints_check_04", + "table": "constraints", + "kind": "CHECK", + "expression": "status IN ('open','mitigating','resolved')", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_escalations_constraint_id_fk", + "table": "escalations", + "kind": "FOREIGN KEY", + "expression": "constraint_id -> constraints.id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_escalations_escalated_by_fk", + "table": "escalations", + "kind": "FOREIGN KEY", + "expression": "escalated_by -> users.id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_escalations_pk", + "table": "escalations", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_escalations_check_01", + "table": "escalations", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_escalations_check_02", + "table": "escalations", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_escalations_check_03", + "table": "escalations", + "kind": "CHECK", + "expression": "level IN ('operational','management','executive')", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_escalations_check_04", + "table": "escalations", + "kind": "CHECK", + "expression": "status IN ('open','acknowledged','closed')", + "migration": "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php" + }, + { + "name": "imz_skills_pk", + "table": "skills", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_skills_check_01", + "table": "skills", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_skills_check_02", + "table": "skills", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_occupations_pk", + "table": "occupations", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_occupations_check_01", + "table": "occupations", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_occupations_check_02", + "table": "occupations", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_training_needs_project_id_fk", + "table": "training_needs", + "kind": "FOREIGN KEY", + "expression": "project_id -> projects.id", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_training_needs_occupation_id_fk", + "table": "training_needs", + "kind": "FOREIGN KEY", + "expression": "occupation_id -> occupations.id", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_training_needs_skill_id_fk", + "table": "training_needs", + "kind": "FOREIGN KEY", + "expression": "skill_id -> skills.id", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_training_needs_pk", + "table": "training_needs", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_training_needs_check_01", + "table": "training_needs", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_training_needs_check_02", + "table": "training_needs", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_training_needs_check_03", + "table": "training_needs", + "kind": "CHECK", + "expression": "quantity>0", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_training_needs_check_04", + "table": "training_needs", + "kind": "CHECK", + "expression": "priority IN ('Must','Should','Could')", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_training_providers_organization_id_fk", + "table": "training_providers", + "kind": "FOREIGN KEY", + "expression": "organization_id -> organizations.id", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_training_providers_pk", + "table": "training_providers", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_training_providers_check_01", + "table": "training_providers", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_training_providers_check_02", + "table": "training_providers", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_training_providers_check_03", + "table": "training_providers", + "kind": "CHECK", + "expression": "status IN ('candidate','approved','inactive')", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_courses_provider_id_fk", + "table": "courses", + "kind": "FOREIGN KEY", + "expression": "provider_id -> training_providers.id", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_courses_pk", + "table": "courses", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_courses_check_01", + "table": "courses", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_courses_check_02", + "table": "courses", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_courses_check_03", + "table": "courses", + "kind": "CHECK", + "expression": "status IN ('draft','published','retired')", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_education_opportunities_course_id_fk", + "table": "education_opportunities", + "kind": "FOREIGN KEY", + "expression": "course_id -> courses.id", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_education_opportunities_geo_location_id_fk", + "table": "education_opportunities", + "kind": "FOREIGN KEY", + "expression": "geo_location_id -> geo_locations.id", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_education_opportunities_pk", + "table": "education_opportunities", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_education_opportunities_check_01", + "table": "education_opportunities", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_education_opportunities_check_02", + "table": "education_opportunities", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_education_opportunities_check_03", + "table": "education_opportunities", + "kind": "CHECK", + "expression": "capacity>=0", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_education_opportunities_check_04", + "table": "education_opportunities", + "kind": "CHECK", + "expression": "status IN ('planned','open','closed','cancelled')", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_education_opportunities_check_05", + "table": "education_opportunities", + "kind": "CHECK", + "expression": "end_date IS NULL OR end_date>=start_date", + "migration": "database/migrations/2026_09_24_009000_investmoz_education.php" + }, + { + "name": "imz_project_documents_project_id_fk", + "table": "project_documents", + "kind": "FOREIGN KEY", + "expression": "project_id -> projects.id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_project_documents_document_id_fk", + "table": "project_documents", + "kind": "FOREIGN KEY", + "expression": "document_id -> documents.id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_project_documents_pk", + "table": "project_documents", + "kind": "PRIMARY KEY", + "expression": "project_id, document_id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_investor_documents_investor_id_fk", + "table": "investor_documents", + "kind": "FOREIGN KEY", + "expression": "investor_id -> investors.id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_investor_documents_document_id_fk", + "table": "investor_documents", + "kind": "FOREIGN KEY", + "expression": "document_id -> documents.id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_investor_documents_pk", + "table": "investor_documents", + "kind": "PRIMARY KEY", + "expression": "investor_id, document_id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_journey_documents_journey_id_fk", + "table": "journey_documents", + "kind": "FOREIGN KEY", + "expression": "journey_id -> investor_journeys.id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_journey_documents_document_id_fk", + "table": "journey_documents", + "kind": "FOREIGN KEY", + "expression": "document_id -> documents.id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_journey_documents_pk", + "table": "journey_documents", + "kind": "PRIMARY KEY", + "expression": "journey_id, document_id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_aftercare_documents_aftercare_plan_id_fk", + "table": "aftercare_documents", + "kind": "FOREIGN KEY", + "expression": "aftercare_plan_id -> aftercare_plans.id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_aftercare_documents_document_id_fk", + "table": "aftercare_documents", + "kind": "FOREIGN KEY", + "expression": "document_id -> documents.id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_aftercare_documents_pk", + "table": "aftercare_documents", + "kind": "PRIMARY KEY", + "expression": "aftercare_plan_id, document_id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_education_documents_education_opportunity_id_fk", + "table": "education_documents", + "kind": "FOREIGN KEY", + "expression": "education_opportunity_id -> education_opportunities.id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_education_documents_document_id_fk", + "table": "education_documents", + "kind": "FOREIGN KEY", + "expression": "document_id -> documents.id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_education_documents_pk", + "table": "education_documents", + "kind": "PRIMARY KEY", + "expression": "education_opportunity_id, document_id", + "migration": "database/migrations/2026_09_24_009500_investmoz_document_links.php" + }, + { + "name": "imz_audit_events_actor_user_id_fk", + "table": "audit_events", + "kind": "FOREIGN KEY", + "expression": "actor_user_id -> users.id", + "migration": "database/migrations/2026_09_24_010000_investmoz_governance_ai.php" + }, + { + "name": "imz_audit_events_pk", + "table": "audit_events", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_010000_investmoz_governance_ai.php" + }, + { + "name": "imz_audit_events_check_01", + "table": "audit_events", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_010000_investmoz_governance_ai.php" + }, + { + "name": "imz_outbox_events_pk", + "table": "outbox_events", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_outbox_events_check_01", + "table": "outbox_events", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_outbox_events_check_02", + "table": "outbox_events", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_outbox_events_check_03", + "table": "outbox_events", + "kind": "CHECK", + "expression": "status IN ('pending','published','failed')", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_inbox_events_pk", + "table": "inbox_events", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_inbox_events_check_01", + "table": "inbox_events", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_inbox_events_check_02", + "table": "inbox_events", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_inbox_events_check_03", + "table": "inbox_events", + "kind": "CHECK", + "expression": "status IN ('received','processed','failed')", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_sync_runs_pk", + "table": "sync_runs", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_sync_runs_check_01", + "table": "sync_runs", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_sync_runs_check_02", + "table": "sync_runs", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_sync_runs_check_03", + "table": "sync_runs", + "kind": "CHECK", + "expression": "direction IN ('inbound','outbound')", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_sync_runs_check_04", + "table": "sync_runs", + "kind": "CHECK", + "expression": "status IN ('running','succeeded','failed','cancelled')", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_reconciliation_issues_sync_run_id_fk", + "table": "reconciliation_issues", + "kind": "FOREIGN KEY", + "expression": "sync_run_id -> sync_runs.id", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_reconciliation_issues_pk", + "table": "reconciliation_issues", + "kind": "PRIMARY KEY", + "expression": "id", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_reconciliation_issues_check_01", + "table": "reconciliation_issues", + "kind": "CHECK", + "expression": "version_no > 0", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_reconciliation_issues_check_02", + "table": "reconciliation_issues", + "kind": "CHECK", + "expression": "data_classification IN ('public','internal','confidential','restricted')", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_reconciliation_issues_check_03", + "table": "reconciliation_issues", + "kind": "CHECK", + "expression": "issue_type IN ('missing','duplicate','orphan','checksum','geometry','value')", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_reconciliation_issues_check_04", + "table": "reconciliation_issues", + "kind": "CHECK", + "expression": "severity IN ('warning','error')", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + }, + { + "name": "imz_reconciliation_issues_check_05", + "table": "reconciliation_issues", + "kind": "CHECK", + "expression": "status IN ('open','accepted','resolved')", + "migration": "database/migrations/2026_09_24_011000_investmoz_integrations.php" + } + ], + "indexes": [ + { + "name": "imz_organizations_source_system_external_id_uq", + "table": "organizations", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_organizations_source_system_external_id_uq ON public.organizations USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_organizations_updated_at_idx", + "table": "organizations", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_organizations_updated_at_idx ON public.organizations USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_memberships_user_id_idx", + "table": "memberships", + "columns": [ + "user_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_memberships_user_id_idx ON public.memberships USING btree (user_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_memberships_organization_id_idx", + "table": "memberships", + "columns": [ + "organization_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_memberships_organization_id_idx ON public.memberships USING btree (organization_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_memberships_user_id_organization_id_uq", + "table": "memberships", + "columns": [ + "user_id", + "organization_id" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_memberships_user_id_organization_id_uq ON public.memberships USING btree (user_id, organization_id) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_memberships_source_system_external_id_uq", + "table": "memberships", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_memberships_source_system_external_id_uq ON public.memberships USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_memberships_updated_at_idx", + "table": "memberships", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_memberships_updated_at_idx ON public.memberships USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_consents_user_id_idx", + "table": "consents", + "columns": [ + "user_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_consents_user_id_idx ON public.consents USING btree (user_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_consents_source_system_external_id_uq", + "table": "consents", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_consents_source_system_external_id_uq ON public.consents USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_consents_updated_at_idx", + "table": "consents", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_consents_updated_at_idx ON public.consents USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_consents_status_idx", + "table": "consents", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_consents_status_idx ON public.consents USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_documents_storage_key_uq", + "table": "documents", + "columns": [ + "storage_key" + ], + "unique": true, + "predicate": null, + "method": "btree", + "reason": "Unicidade de negócio", + "sql": "CREATE UNIQUE INDEX imz_documents_storage_key_uq ON public.documents USING btree (storage_key);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_documents_source_system_external_id_uq", + "table": "documents", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_documents_source_system_external_id_uq ON public.documents USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_documents_updated_at_idx", + "table": "documents", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_documents_updated_at_idx ON public.documents USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_documents_status_idx", + "table": "documents", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_documents_status_idx ON public.documents USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_geo_locations_parent_id_idx", + "table": "geo_locations", + "columns": [ + "parent_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_geo_locations_parent_id_idx ON public.geo_locations USING btree (parent_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_geo_locations_source_system_external_id_uq", + "table": "geo_locations", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_geo_locations_source_system_external_id_uq ON public.geo_locations USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_geo_locations_location_type_code_uq", + "table": "geo_locations", + "columns": [ + "location_type", + "code" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_geo_locations_location_type_code_uq ON public.geo_locations USING btree (location_type, code) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_geo_locations_updated_at_idx", + "table": "geo_locations", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_geo_locations_updated_at_idx ON public.geo_locations USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_geo_geometries_geo_location_id_idx", + "table": "geo_geometries", + "columns": [ + "geo_location_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_geo_geometries_geo_location_id_idx ON public.geo_geometries USING btree (geo_location_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_geo_geometries_geo_location_id_geometry_type_uq", + "table": "geo_geometries", + "columns": [ + "geo_location_id", + "geometry_type" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_geo_geometries_geo_location_id_geometry_type_uq ON public.geo_geometries USING btree (geo_location_id, geometry_type) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_geo_geometries_source_system_external_id_uq", + "table": "geo_geometries", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_geo_geometries_source_system_external_id_uq ON public.geo_geometries USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_geo_geometries_geom_idx", + "table": "geo_geometries", + "columns": [ + "geom" + ], + "unique": false, + "predicate": null, + "method": "gist", + "reason": "Intersecção e proximidade espacial SRID 4326", + "sql": "CREATE INDEX imz_geo_geometries_geom_idx ON public.geo_geometries USING gist (geom);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_geo_geometries_updated_at_idx", + "table": "geo_geometries", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_geo_geometries_updated_at_idx ON public.geo_geometries USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_projects_code_uq", + "table": "projects", + "columns": [ + "code" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade de negócio", + "sql": "CREATE UNIQUE INDEX imz_projects_code_uq ON public.projects USING btree (code) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_projects_source_system_external_id_uq", + "table": "projects", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_projects_source_system_external_id_uq ON public.projects USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_projects_updated_at_idx", + "table": "projects", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_projects_updated_at_idx ON public.projects USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_projects_status_idx", + "table": "projects", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_projects_status_idx ON public.projects USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_project_locations_project_id_idx", + "table": "project_locations", + "columns": [ + "project_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_project_locations_project_id_idx ON public.project_locations USING btree (project_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_project_locations_geo_location_id_idx", + "table": "project_locations", + "columns": [ + "geo_location_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_project_locations_geo_location_id_idx ON public.project_locations USING btree (geo_location_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_project_locations_project_id_geo_location_id_role_uq", + "table": "project_locations", + "columns": [ + "project_id", + "geo_location_id", + "role" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_project_locations_project_id_geo_location_id_role_uq ON public.project_locations USING btree (project_id, geo_location_id, role) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_project_locations_source_system_external_id_uq", + "table": "project_locations", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_project_locations_source_system_external_id_uq ON public.project_locations USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_project_locations_updated_at_idx", + "table": "project_locations", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_project_locations_updated_at_idx ON public.project_locations USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_economic_clusters_code_uq", + "table": "economic_clusters", + "columns": [ + "code" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade de negócio", + "sql": "CREATE UNIQUE INDEX imz_economic_clusters_code_uq ON public.economic_clusters USING btree (code) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_economic_clusters_source_system_external_id_uq", + "table": "economic_clusters", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_economic_clusters_source_system_external_id_uq ON public.economic_clusters USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_economic_clusters_updated_at_idx", + "table": "economic_clusters", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_economic_clusters_updated_at_idx ON public.economic_clusters USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_economic_clusters_status_idx", + "table": "economic_clusters", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_economic_clusters_status_idx ON public.economic_clusters USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_value_chains_cluster_id_idx", + "table": "value_chains", + "columns": [ + "cluster_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_value_chains_cluster_id_idx ON public.value_chains USING btree (cluster_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_value_chains_cluster_id_code_uq", + "table": "value_chains", + "columns": [ + "cluster_id", + "code" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_value_chains_cluster_id_code_uq ON public.value_chains USING btree (cluster_id, code) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_value_chains_source_system_external_id_uq", + "table": "value_chains", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_value_chains_source_system_external_id_uq ON public.value_chains USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_value_chains_updated_at_idx", + "table": "value_chains", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_value_chains_updated_at_idx ON public.value_chains USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_value_chain_links_from_value_chain_id_idx", + "table": "value_chain_links", + "columns": [ + "from_value_chain_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_value_chain_links_from_value_chain_id_idx ON public.value_chain_links USING btree (from_value_chain_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_value_chain_links_to_value_chain_id_idx", + "table": "value_chain_links", + "columns": [ + "to_value_chain_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_value_chain_links_to_value_chain_id_idx ON public.value_chain_links USING btree (to_value_chain_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c", + "table": "value_chain_links", + "columns": [ + "from_value_chain_id", + "to_value_chain_id", + "link_type" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_value_chain_links_from_value_chain_id_to_value_c_f6a1a6162c ON public.value_chain_links USING btree (from_value_chain_id, to_value_chain_id, link_type) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_value_chain_links_source_system_external_id_uq", + "table": "value_chain_links", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_value_chain_links_source_system_external_id_uq ON public.value_chain_links USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_value_chain_links_updated_at_idx", + "table": "value_chain_links", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_value_chain_links_updated_at_idx ON public.value_chain_links USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_cluster_projects_cluster_id_idx", + "table": "cluster_projects", + "columns": [ + "cluster_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_cluster_projects_cluster_id_idx ON public.cluster_projects USING btree (cluster_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_cluster_projects_project_id_idx", + "table": "cluster_projects", + "columns": [ + "project_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_cluster_projects_project_id_idx ON public.cluster_projects USING btree (project_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_cluster_projects_cluster_id_project_id_uq", + "table": "cluster_projects", + "columns": [ + "cluster_id", + "project_id" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_cluster_projects_cluster_id_project_id_uq ON public.cluster_projects USING btree (cluster_id, project_id) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_cluster_projects_source_system_external_id_uq", + "table": "cluster_projects", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_cluster_projects_source_system_external_id_uq ON public.cluster_projects USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_cluster_projects_updated_at_idx", + "table": "cluster_projects", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_cluster_projects_updated_at_idx ON public.cluster_projects USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_anchor_project_links_anchor_project_id_idx", + "table": "anchor_project_links", + "columns": [ + "anchor_project_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_anchor_project_links_anchor_project_id_idx ON public.anchor_project_links USING btree (anchor_project_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_anchor_project_links_cluster_id_idx", + "table": "anchor_project_links", + "columns": [ + "cluster_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_anchor_project_links_cluster_id_idx ON public.anchor_project_links USING btree (cluster_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_anchor_project_links_anchor_project_id_cluster_id_uq", + "table": "anchor_project_links", + "columns": [ + "anchor_project_id", + "cluster_id" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_anchor_project_links_anchor_project_id_cluster_id_uq ON public.anchor_project_links USING btree (anchor_project_id, cluster_id) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_anchor_project_links_source_system_external_id_uq", + "table": "anchor_project_links", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_anchor_project_links_source_system_external_id_uq ON public.anchor_project_links USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_anchor_project_links_updated_at_idx", + "table": "anchor_project_links", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_anchor_project_links_updated_at_idx ON public.anchor_project_links USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_complementary_project_links_anchor_project_id_idx", + "table": "complementary_project_links", + "columns": [ + "anchor_project_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_complementary_project_links_anchor_project_id_idx ON public.complementary_project_links USING btree (anchor_project_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_complementary_project_links_complementary_project_id_idx", + "table": "complementary_project_links", + "columns": [ + "complementary_project_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_complementary_project_links_complementary_project_id_idx ON public.complementary_project_links USING btree (complementary_project_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_complementary_project_links_anchor_project_id_co_90eddcd25a", + "table": "complementary_project_links", + "columns": [ + "anchor_project_id", + "complementary_project_id", + "relation_type" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_complementary_project_links_anchor_project_id_co_90eddcd25a ON public.complementary_project_links USING btree (anchor_project_id, complementary_project_id, relation_type) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_complementary_project_links_source_system_external_id_uq", + "table": "complementary_project_links", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_complementary_project_links_source_system_external_id_uq ON public.complementary_project_links USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_complementary_project_links_updated_at_idx", + "table": "complementary_project_links", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_complementary_project_links_updated_at_idx ON public.complementary_project_links USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_development_corridors_code_uq", + "table": "development_corridors", + "columns": [ + "code" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade de negócio", + "sql": "CREATE UNIQUE INDEX imz_development_corridors_code_uq ON public.development_corridors USING btree (code) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_development_corridors_source_system_external_id_uq", + "table": "development_corridors", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_development_corridors_source_system_external_id_uq ON public.development_corridors USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_development_corridors_updated_at_idx", + "table": "development_corridors", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_development_corridors_updated_at_idx ON public.development_corridors USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_development_corridors_status_idx", + "table": "development_corridors", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_development_corridors_status_idx ON public.development_corridors USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_locations_corridor_id_idx", + "table": "corridor_locations", + "columns": [ + "corridor_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_corridor_locations_corridor_id_idx ON public.corridor_locations USING btree (corridor_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_locations_geo_location_id_idx", + "table": "corridor_locations", + "columns": [ + "geo_location_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_corridor_locations_geo_location_id_idx ON public.corridor_locations USING btree (geo_location_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_locations_corridor_id_geo_location_id_uq", + "table": "corridor_locations", + "columns": [ + "corridor_id", + "geo_location_id" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_corridor_locations_corridor_id_geo_location_id_uq ON public.corridor_locations USING btree (corridor_id, geo_location_id) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_locations_corridor_id_sequence_no_uq", + "table": "corridor_locations", + "columns": [ + "corridor_id", + "sequence_no" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_corridor_locations_corridor_id_sequence_no_uq ON public.corridor_locations USING btree (corridor_id, sequence_no) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_locations_source_system_external_id_uq", + "table": "corridor_locations", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_corridor_locations_source_system_external_id_uq ON public.corridor_locations USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_locations_updated_at_idx", + "table": "corridor_locations", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_corridor_locations_updated_at_idx ON public.corridor_locations USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_projects_corridor_id_idx", + "table": "corridor_projects", + "columns": [ + "corridor_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_corridor_projects_corridor_id_idx ON public.corridor_projects USING btree (corridor_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_projects_project_id_idx", + "table": "corridor_projects", + "columns": [ + "project_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_corridor_projects_project_id_idx ON public.corridor_projects USING btree (project_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_projects_corridor_id_project_id_uq", + "table": "corridor_projects", + "columns": [ + "corridor_id", + "project_id" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_corridor_projects_corridor_id_project_id_uq ON public.corridor_projects USING btree (corridor_id, project_id) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_projects_source_system_external_id_uq", + "table": "corridor_projects", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_corridor_projects_source_system_external_id_uq ON public.corridor_projects USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_projects_updated_at_idx", + "table": "corridor_projects", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_corridor_projects_updated_at_idx ON public.corridor_projects USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_clusters_corridor_id_idx", + "table": "corridor_clusters", + "columns": [ + "corridor_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_corridor_clusters_corridor_id_idx ON public.corridor_clusters USING btree (corridor_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_clusters_cluster_id_idx", + "table": "corridor_clusters", + "columns": [ + "cluster_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_corridor_clusters_cluster_id_idx ON public.corridor_clusters USING btree (cluster_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_clusters_corridor_id_cluster_id_uq", + "table": "corridor_clusters", + "columns": [ + "corridor_id", + "cluster_id" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_corridor_clusters_corridor_id_cluster_id_uq ON public.corridor_clusters USING btree (corridor_id, cluster_id) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_clusters_source_system_external_id_uq", + "table": "corridor_clusters", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_corridor_clusters_source_system_external_id_uq ON public.corridor_clusters USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_corridor_clusters_updated_at_idx", + "table": "corridor_clusters", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_corridor_clusters_updated_at_idx ON public.corridor_clusters USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_investors_organization_id_idx", + "table": "investors", + "columns": [ + "organization_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_investors_organization_id_idx ON public.investors USING btree (organization_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_investors_source_system_external_id_uq", + "table": "investors", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_investors_source_system_external_id_uq ON public.investors USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_investors_updated_at_idx", + "table": "investors", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_investors_updated_at_idx ON public.investors USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_investors_status_idx", + "table": "investors", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_investors_status_idx ON public.investors USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_contacts_source_system_external_id_uq", + "table": "contacts", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_contacts_source_system_external_id_uq ON public.contacts USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_contacts_updated_at_idx", + "table": "contacts", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_contacts_updated_at_idx ON public.contacts USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_investor_contacts_investor_id_idx", + "table": "investor_contacts", + "columns": [ + "investor_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_investor_contacts_investor_id_idx ON public.investor_contacts USING btree (investor_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_investor_contacts_contact_id_idx", + "table": "investor_contacts", + "columns": [ + "contact_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_investor_contacts_contact_id_idx ON public.investor_contacts USING btree (contact_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_investor_contacts_updated_at_idx", + "table": "investor_contacts", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_investor_contacts_updated_at_idx ON public.investor_contacts USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_leads_investor_id_idx", + "table": "leads", + "columns": [ + "investor_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_leads_investor_id_idx ON public.leads USING btree (investor_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_leads_owner_user_id_idx", + "table": "leads", + "columns": [ + "owner_user_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_leads_owner_user_id_idx ON public.leads USING btree (owner_user_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_leads_source_system_external_id_uq", + "table": "leads", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_leads_source_system_external_id_uq ON public.leads USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_leads_updated_at_idx", + "table": "leads", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_leads_updated_at_idx ON public.leads USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_leads_status_idx", + "table": "leads", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_leads_status_idx ON public.leads USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_crm_opportunities_lead_id_idx", + "table": "crm_opportunities", + "columns": [ + "lead_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_crm_opportunities_lead_id_idx ON public.crm_opportunities USING btree (lead_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_crm_opportunities_project_id_idx", + "table": "crm_opportunities", + "columns": [ + "project_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_crm_opportunities_project_id_idx ON public.crm_opportunities USING btree (project_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_crm_opportunities_owner_user_id_idx", + "table": "crm_opportunities", + "columns": [ + "owner_user_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_crm_opportunities_owner_user_id_idx ON public.crm_opportunities USING btree (owner_user_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_crm_opportunities_source_system_external_id_uq", + "table": "crm_opportunities", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_crm_opportunities_source_system_external_id_uq ON public.crm_opportunities USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_crm_opportunities_updated_at_idx", + "table": "crm_opportunities", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_crm_opportunities_updated_at_idx ON public.crm_opportunities USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_activities_investor_id_idx", + "table": "activities", + "columns": [ + "investor_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_activities_investor_id_idx ON public.activities USING btree (investor_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_activities_opportunity_id_idx", + "table": "activities", + "columns": [ + "opportunity_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_activities_opportunity_id_idx ON public.activities USING btree (opportunity_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_activities_actor_user_id_idx", + "table": "activities", + "columns": [ + "actor_user_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_activities_actor_user_id_idx ON public.activities USING btree (actor_user_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_activities_source_system_external_id_uq", + "table": "activities", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_activities_source_system_external_id_uq ON public.activities USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_activities_updated_at_idx", + "table": "activities", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_activities_updated_at_idx ON public.activities USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_tasks_assignee_user_id_idx", + "table": "tasks", + "columns": [ + "assignee_user_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_tasks_assignee_user_id_idx ON public.tasks USING btree (assignee_user_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_tasks_opportunity_id_idx", + "table": "tasks", + "columns": [ + "opportunity_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_tasks_opportunity_id_idx ON public.tasks USING btree (opportunity_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_tasks_source_system_external_id_uq", + "table": "tasks", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_tasks_source_system_external_id_uq ON public.tasks USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_tasks_updated_at_idx", + "table": "tasks", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_tasks_updated_at_idx ON public.tasks USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_tasks_status_idx", + "table": "tasks", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_tasks_status_idx ON public.tasks USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_investor_journeys_investor_id_idx", + "table": "investor_journeys", + "columns": [ + "investor_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_investor_journeys_investor_id_idx ON public.investor_journeys USING btree (investor_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_investor_journeys_opportunity_id_idx", + "table": "investor_journeys", + "columns": [ + "opportunity_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_investor_journeys_opportunity_id_idx ON public.investor_journeys USING btree (opportunity_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_investor_journeys_source_system_external_id_uq", + "table": "investor_journeys", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_investor_journeys_source_system_external_id_uq ON public.investor_journeys USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_investor_journeys_updated_at_idx", + "table": "investor_journeys", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_investor_journeys_updated_at_idx ON public.investor_journeys USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_investor_journeys_status_idx", + "table": "investor_journeys", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_investor_journeys_status_idx ON public.investor_journeys USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_journey_steps_journey_id_idx", + "table": "journey_steps", + "columns": [ + "journey_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_journey_steps_journey_id_idx ON public.journey_steps USING btree (journey_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_journey_steps_journey_id_sequence_no_uq", + "table": "journey_steps", + "columns": [ + "journey_id", + "sequence_no" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_journey_steps_journey_id_sequence_no_uq ON public.journey_steps USING btree (journey_id, sequence_no) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_journey_steps_journey_id_step_code_uq", + "table": "journey_steps", + "columns": [ + "journey_id", + "step_code" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_journey_steps_journey_id_step_code_uq ON public.journey_steps USING btree (journey_id, step_code) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_journey_steps_source_system_external_id_uq", + "table": "journey_steps", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_journey_steps_source_system_external_id_uq ON public.journey_steps USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_journey_steps_updated_at_idx", + "table": "journey_steps", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_journey_steps_updated_at_idx ON public.journey_steps USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_journey_steps_status_idx", + "table": "journey_steps", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_journey_steps_status_idx ON public.journey_steps USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_submissions_journey_step_id_idx", + "table": "submissions", + "columns": [ + "journey_step_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_submissions_journey_step_id_idx ON public.submissions USING btree (journey_step_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_submissions_submitted_by_idx", + "table": "submissions", + "columns": [ + "submitted_by" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_submissions_submitted_by_idx ON public.submissions USING btree (submitted_by);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_submissions_source_system_external_id_uq", + "table": "submissions", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_submissions_source_system_external_id_uq ON public.submissions USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_submissions_updated_at_idx", + "table": "submissions", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_submissions_updated_at_idx ON public.submissions USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_submissions_status_idx", + "table": "submissions", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_submissions_status_idx ON public.submissions USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_aftercare_plans_investor_id_idx", + "table": "aftercare_plans", + "columns": [ + "investor_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_aftercare_plans_investor_id_idx ON public.aftercare_plans USING btree (investor_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_aftercare_plans_project_id_idx", + "table": "aftercare_plans", + "columns": [ + "project_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_aftercare_plans_project_id_idx ON public.aftercare_plans USING btree (project_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_aftercare_plans_owner_user_id_idx", + "table": "aftercare_plans", + "columns": [ + "owner_user_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_aftercare_plans_owner_user_id_idx ON public.aftercare_plans USING btree (owner_user_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_aftercare_plans_source_system_external_id_uq", + "table": "aftercare_plans", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_aftercare_plans_source_system_external_id_uq ON public.aftercare_plans USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_aftercare_plans_updated_at_idx", + "table": "aftercare_plans", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_aftercare_plans_updated_at_idx ON public.aftercare_plans USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_aftercare_plans_status_idx", + "table": "aftercare_plans", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_aftercare_plans_status_idx ON public.aftercare_plans USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_constraints_aftercare_plan_id_idx", + "table": "constraints", + "columns": [ + "aftercare_plan_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_constraints_aftercare_plan_id_idx ON public.constraints USING btree (aftercare_plan_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_constraints_source_system_external_id_uq", + "table": "constraints", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_constraints_source_system_external_id_uq ON public.constraints USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_constraints_updated_at_idx", + "table": "constraints", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_constraints_updated_at_idx ON public.constraints USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_constraints_status_idx", + "table": "constraints", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_constraints_status_idx ON public.constraints USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_escalations_constraint_id_idx", + "table": "escalations", + "columns": [ + "constraint_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_escalations_constraint_id_idx ON public.escalations USING btree (constraint_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_escalations_escalated_by_idx", + "table": "escalations", + "columns": [ + "escalated_by" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_escalations_escalated_by_idx ON public.escalations USING btree (escalated_by);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_escalations_source_system_external_id_uq", + "table": "escalations", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_escalations_source_system_external_id_uq ON public.escalations USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_escalations_updated_at_idx", + "table": "escalations", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_escalations_updated_at_idx ON public.escalations USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_escalations_status_idx", + "table": "escalations", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_escalations_status_idx ON public.escalations USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_skills_code_uq", + "table": "skills", + "columns": [ + "code" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade de negócio", + "sql": "CREATE UNIQUE INDEX imz_skills_code_uq ON public.skills USING btree (code) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_skills_source_system_external_id_uq", + "table": "skills", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_skills_source_system_external_id_uq ON public.skills USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_skills_updated_at_idx", + "table": "skills", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_skills_updated_at_idx ON public.skills USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_occupations_code_uq", + "table": "occupations", + "columns": [ + "code" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade de negócio", + "sql": "CREATE UNIQUE INDEX imz_occupations_code_uq ON public.occupations USING btree (code) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_occupations_source_system_external_id_uq", + "table": "occupations", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_occupations_source_system_external_id_uq ON public.occupations USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_occupations_updated_at_idx", + "table": "occupations", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_occupations_updated_at_idx ON public.occupations USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_training_needs_project_id_idx", + "table": "training_needs", + "columns": [ + "project_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_training_needs_project_id_idx ON public.training_needs USING btree (project_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_training_needs_occupation_id_idx", + "table": "training_needs", + "columns": [ + "occupation_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_training_needs_occupation_id_idx ON public.training_needs USING btree (occupation_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_training_needs_skill_id_idx", + "table": "training_needs", + "columns": [ + "skill_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_training_needs_skill_id_idx ON public.training_needs USING btree (skill_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_training_needs_source_system_external_id_uq", + "table": "training_needs", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_training_needs_source_system_external_id_uq ON public.training_needs USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_training_needs_updated_at_idx", + "table": "training_needs", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_training_needs_updated_at_idx ON public.training_needs USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_training_providers_organization_id_idx", + "table": "training_providers", + "columns": [ + "organization_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_training_providers_organization_id_idx ON public.training_providers USING btree (organization_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_training_providers_source_system_external_id_uq", + "table": "training_providers", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_training_providers_source_system_external_id_uq ON public.training_providers USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_training_providers_updated_at_idx", + "table": "training_providers", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_training_providers_updated_at_idx ON public.training_providers USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_training_providers_status_idx", + "table": "training_providers", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_training_providers_status_idx ON public.training_providers USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_courses_provider_id_idx", + "table": "courses", + "columns": [ + "provider_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_courses_provider_id_idx ON public.courses USING btree (provider_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_courses_provider_id_code_uq", + "table": "courses", + "columns": [ + "provider_id", + "code" + ], + "unique": true, + "predicate": "deleted_at IS NULL", + "method": "btree", + "reason": "Unicidade activa", + "sql": "CREATE UNIQUE INDEX imz_courses_provider_id_code_uq ON public.courses USING btree (provider_id, code) WHERE deleted_at IS NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_courses_source_system_external_id_uq", + "table": "courses", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_courses_source_system_external_id_uq ON public.courses USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_courses_updated_at_idx", + "table": "courses", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_courses_updated_at_idx ON public.courses USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_courses_status_idx", + "table": "courses", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_courses_status_idx ON public.courses USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_education_opportunities_course_id_idx", + "table": "education_opportunities", + "columns": [ + "course_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_education_opportunities_course_id_idx ON public.education_opportunities USING btree (course_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_education_opportunities_geo_location_id_idx", + "table": "education_opportunities", + "columns": [ + "geo_location_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_education_opportunities_geo_location_id_idx ON public.education_opportunities USING btree (geo_location_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_education_opportunities_source_system_external_id_uq", + "table": "education_opportunities", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_education_opportunities_source_system_external_id_uq ON public.education_opportunities USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_education_opportunities_updated_at_idx", + "table": "education_opportunities", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_education_opportunities_updated_at_idx ON public.education_opportunities USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_education_opportunities_status_idx", + "table": "education_opportunities", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_education_opportunities_status_idx ON public.education_opportunities USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_project_documents_project_id_idx", + "table": "project_documents", + "columns": [ + "project_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_project_documents_project_id_idx ON public.project_documents USING btree (project_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_project_documents_document_id_idx", + "table": "project_documents", + "columns": [ + "document_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_project_documents_document_id_idx ON public.project_documents USING btree (document_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_investor_documents_investor_id_idx", + "table": "investor_documents", + "columns": [ + "investor_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_investor_documents_investor_id_idx ON public.investor_documents USING btree (investor_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_investor_documents_document_id_idx", + "table": "investor_documents", + "columns": [ + "document_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_investor_documents_document_id_idx ON public.investor_documents USING btree (document_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_journey_documents_journey_id_idx", + "table": "journey_documents", + "columns": [ + "journey_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_journey_documents_journey_id_idx ON public.journey_documents USING btree (journey_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_journey_documents_document_id_idx", + "table": "journey_documents", + "columns": [ + "document_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_journey_documents_document_id_idx ON public.journey_documents USING btree (document_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_aftercare_documents_aftercare_plan_id_idx", + "table": "aftercare_documents", + "columns": [ + "aftercare_plan_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_aftercare_documents_aftercare_plan_id_idx ON public.aftercare_documents USING btree (aftercare_plan_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_aftercare_documents_document_id_idx", + "table": "aftercare_documents", + "columns": [ + "document_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_aftercare_documents_document_id_idx ON public.aftercare_documents USING btree (document_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_education_documents_education_opportunity_id_idx", + "table": "education_documents", + "columns": [ + "education_opportunity_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_education_documents_education_opportunity_id_idx ON public.education_documents USING btree (education_opportunity_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_education_documents_document_id_idx", + "table": "education_documents", + "columns": [ + "document_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_education_documents_document_id_idx ON public.education_documents USING btree (document_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_audit_events_actor_user_id_idx", + "table": "audit_events", + "columns": [ + "actor_user_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_audit_events_actor_user_id_idx ON public.audit_events USING btree (actor_user_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_audit_events_source_system_external_id_uq", + "table": "audit_events", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_audit_events_source_system_external_id_uq ON public.audit_events USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_outbox_events_source_system_external_id_uq", + "table": "outbox_events", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_outbox_events_source_system_external_id_uq ON public.outbox_events USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_outbox_events_updated_at_idx", + "table": "outbox_events", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_outbox_events_updated_at_idx ON public.outbox_events USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_outbox_events_status_idx", + "table": "outbox_events", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_outbox_events_status_idx ON public.outbox_events USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_inbox_events_message_id_uq", + "table": "inbox_events", + "columns": [ + "message_id" + ], + "unique": true, + "predicate": null, + "method": "btree", + "reason": "Unicidade de negócio", + "sql": "CREATE UNIQUE INDEX imz_inbox_events_message_id_uq ON public.inbox_events USING btree (message_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_inbox_events_source_system_external_id_uq", + "table": "inbox_events", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_inbox_events_source_system_external_id_uq ON public.inbox_events USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_inbox_events_updated_at_idx", + "table": "inbox_events", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_inbox_events_updated_at_idx ON public.inbox_events USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_inbox_events_status_idx", + "table": "inbox_events", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_inbox_events_status_idx ON public.inbox_events USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_sync_runs_source_system_external_id_uq", + "table": "sync_runs", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_sync_runs_source_system_external_id_uq ON public.sync_runs USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_sync_runs_updated_at_idx", + "table": "sync_runs", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_sync_runs_updated_at_idx ON public.sync_runs USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_sync_runs_status_idx", + "table": "sync_runs", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_sync_runs_status_idx ON public.sync_runs USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_reconciliation_issues_sync_run_id_idx", + "table": "reconciliation_issues", + "columns": [ + "sync_run_id" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Consulta por chave estrangeira", + "sql": "CREATE INDEX imz_reconciliation_issues_sync_run_id_idx ON public.reconciliation_issues USING btree (sync_run_id);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_reconciliation_issues_source_system_external_id_uq", + "table": "reconciliation_issues", + "columns": [ + "source_system", + "external_id" + ], + "unique": true, + "predicate": "external_id IS NOT NULL", + "method": "btree", + "reason": "Deduplicação de importação", + "sql": "CREATE UNIQUE INDEX imz_reconciliation_issues_source_system_external_id_uq ON public.reconciliation_issues USING btree (source_system, external_id) WHERE external_id IS NOT NULL;", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_reconciliation_issues_updated_at_idx", + "table": "reconciliation_issues", + "columns": [ + "updated_at" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_reconciliation_issues_updated_at_idx ON public.reconciliation_issues USING btree (updated_at);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_reconciliation_issues_status_idx", + "table": "reconciliation_issues", + "columns": [ + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Filtragem operacional e sincronização incremental", + "sql": "CREATE INDEX imz_reconciliation_issues_status_idx ON public.reconciliation_issues USING btree (status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_geo_geometries_properties_idx", + "table": "geo_geometries", + "columns": [ + "properties" + ], + "unique": false, + "predicate": null, + "method": "gin", + "reason": "Consultas de contenção JSONB documentadas em A.25/A.26", + "sql": "CREATE INDEX imz_geo_geometries_properties_idx ON public.geo_geometries USING gin (properties);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_projects_metadata_idx", + "table": "projects", + "columns": [ + "metadata" + ], + "unique": false, + "predicate": null, + "method": "gin", + "reason": "Consultas de contenção JSONB documentadas em A.25/A.26", + "sql": "CREATE INDEX imz_projects_metadata_idx ON public.projects USING gin (metadata);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_submissions_payload_idx", + "table": "submissions", + "columns": [ + "payload" + ], + "unique": false, + "predicate": null, + "method": "gin", + "reason": "Consultas de contenção JSONB documentadas em A.25/A.26", + "sql": "CREATE INDEX imz_submissions_payload_idx ON public.submissions USING gin (payload);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_audit_events_after_data_idx", + "table": "audit_events", + "columns": [ + "after_data" + ], + "unique": false, + "predicate": null, + "method": "gin", + "reason": "Consultas de contenção JSONB documentadas em A.25/A.26", + "sql": "CREATE INDEX imz_audit_events_after_data_idx ON public.audit_events USING gin (after_data);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_outbox_events_status_available_at_idx", + "table": "outbox_events", + "columns": [ + "status", + "available_at" + ], + "unique": false, + "predicate": "status = 'pending'", + "method": "btree", + "reason": "Selecção dos eventos pendentes para entrega", + "sql": "CREATE INDEX imz_outbox_events_status_available_at_idx ON public.outbox_events USING btree (status, available_at) WHERE status = 'pending';", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + }, + { + "name": "imz_reconciliation_issues_sync_run_id_status_idx", + "table": "reconciliation_issues", + "columns": [ + "sync_run_id", + "status" + ], + "unique": false, + "predicate": null, + "method": "btree", + "reason": "Reconciliação por execução e estado", + "sql": "CREATE INDEX imz_reconciliation_issues_sync_run_id_status_idx ON public.reconciliation_issues USING btree (sync_run_id, status);", + "migration": "database/migrations/2026_09_24_012000_investmoz_indexes.php" + } + ], + "triggers": [ + { + "name": "imz_organizations_updated", + "table": "organizations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_memberships_updated", + "table": "memberships", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_consents_updated", + "table": "consents", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_documents_updated", + "table": "documents", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_geo_locations_updated", + "table": "geo_locations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_geo_geometries_updated", + "table": "geo_geometries", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_projects_updated", + "table": "projects", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_project_locations_updated", + "table": "project_locations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_economic_clusters_updated", + "table": "economic_clusters", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_value_chains_updated", + "table": "value_chains", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_value_chain_links_updated", + "table": "value_chain_links", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_cluster_projects_updated", + "table": "cluster_projects", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_anchor_project_links_updated", + "table": "anchor_project_links", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_complementary_project_links_updated", + "table": "complementary_project_links", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_development_corridors_updated", + "table": "development_corridors", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_corridor_locations_updated", + "table": "corridor_locations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_corridor_projects_updated", + "table": "corridor_projects", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_corridor_clusters_updated", + "table": "corridor_clusters", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_investors_updated", + "table": "investors", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_contacts_updated", + "table": "contacts", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_investor_contacts_updated", + "table": "investor_contacts", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_leads_updated", + "table": "leads", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_crm_opportunities_updated", + "table": "crm_opportunities", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_activities_updated", + "table": "activities", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_tasks_updated", + "table": "tasks", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_investor_journeys_updated", + "table": "investor_journeys", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_journey_steps_updated", + "table": "journey_steps", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_submissions_updated", + "table": "submissions", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_aftercare_plans_updated", + "table": "aftercare_plans", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_constraints_updated", + "table": "constraints", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_escalations_updated", + "table": "escalations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_skills_updated", + "table": "skills", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_occupations_updated", + "table": "occupations", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_training_needs_updated", + "table": "training_needs", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_training_providers_updated", + "table": "training_providers", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_courses_updated", + "table": "courses", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_education_opportunities_updated", + "table": "education_opportunities", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_outbox_events_updated", + "table": "outbox_events", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_inbox_events_updated", + "table": "inbox_events", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_sync_runs_updated", + "table": "sync_runs", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_reconciliation_issues_updated", + "table": "reconciliation_issues", + "event": "BEFORE UPDATE", + "function": "investmoz_set_updated_at", + "level": "ROW" + }, + { + "name": "imz_audit_events_immutable", + "table": "audit_events", + "event": "BEFORE UPDATE OR DELETE OR TRUNCATE", + "function": "investmoz_reject_audit_change", + "level": "STATEMENT" + }, + { + "name": "imz_outbox_events_envelope", + "table": "outbox_events", + "event": "BEFORE UPDATE OR DELETE", + "function": "investmoz_protect_event_envelope", + "level": "ROW" + }, + { + "name": "imz_outbox_events_no_truncate", + "table": "outbox_events", + "event": "BEFORE TRUNCATE", + "function": "investmoz_reject_audit_change", + "level": "STATEMENT" + }, + { + "name": "imz_inbox_events_envelope", + "table": "inbox_events", + "event": "BEFORE UPDATE OR DELETE", + "function": "investmoz_protect_event_envelope", + "level": "ROW" + }, + { + "name": "imz_inbox_events_no_truncate", + "table": "inbox_events", + "event": "BEFORE TRUNCATE", + "function": "investmoz_reject_audit_change", + "level": "STATEMENT" + } + ], + "functions": [ + "investmoz_set_updated_at", + "investmoz_reject_audit_change", + "investmoz_protect_event_envelope" + ], + "views": [], + "materialized_views": [], + "custom_types": [], + "sequences": [], + "rls_policies": [], + "note": "New reconciled schema only. Existing application schema is catalogued separately." +} diff --git a/docs/investmoz/source-reconciliation.json b/docs/investmoz/source-reconciliation.json new file mode 100644 index 00000000..3c90edc7 --- /dev/null +++ b/docs/investmoz/source-reconciliation.json @@ -0,0 +1,11 @@ +{ + "document": "Documento Base para o Upgrade do Portal de Investimentos da Tempo v4.1", + "sha256": "f0b9815cb177dbb77e19a78106a244d2248f8bd2cbd4aac6c1bbb225885ccad6", + "sections": [ + "A.25", + "A.26", + "A.31" + ], + "authorization": "User approved reconciliation of historical schema with A.31 and installed application.", + "historical_sql_executed": false +} diff --git a/docs/investmoz/suitecrm/architecture.md b/docs/investmoz/suitecrm/architecture.md new file mode 100644 index 00000000..eaddd19c --- /dev/null +++ b/docs/investmoz/suitecrm/architecture.md @@ -0,0 +1,140 @@ +# SuiteCRM — decisão de arquitectura e plano de transição + +Data: 25/09/2026. O utilizador delegou a escolha da solução e confirmou que não existe SuiteCRM nem cliente OAuth. Esta decisão define o destino da arquitectura; não significa que a integração esteja instalada ou aprovada para produção. + +## 1. Diagnóstico + +Portal confirmado: Laravel 13.30.1, React 19.2.8, Inertia React 3.7.0, Inertia Laravel 3.3.3, Fortify 1.39.0, PostgreSQL 17.11/PostGIS 3.6.2. PHP de trabalho 8.5.11; o PATH ainda resolve outro PHP 8.5.0. Filas/cache/sessões usam database. Gestão autorizada por email verificado e lista de gestores; não existe RBAC organizacional completo. + +Há CRM local (`CrmController`, `ConvertInterestToLead`), catálogo público, projectos e clusters. `InterestRequestController` não aceita chave de idempotência; a conversão em lead é idempotente por pedido, mas a submissão pública ainda não. Outbox/inbox/reconciliação existem como tabelas, sem processadores. O agregado da outbox exige UUID, enquanto pedidos usam bigint. A referência IR não é credencial de acesso. + +Não existe SuiteCrmClient, instalação SuiteCRM ou implantação Docker do portal. Docker não foi encontrado e o executável WSL informa que o subsistema não está instalado. Não foi alterado o sistema operativo. + +## 2. Decisões e pendências + +Escolha para homologação: SuiteCRM **8.10.2**, PHP **8.4**, Apache **2.4**, MariaDB **11.4**, isolados em Linux/Docker. A release foi confirmada no repositório oficial e a combinação na matriz oficial. O PHP 8.5 do portal não será usado para executar o CRM. O pacote pré-compilado dispensa ferramentas de compilação Angular na imagem do CRM. + +SuiteCRM será o único sistema de escrita comercial após a transição. As páginas comerciais do portal passarão a consulta ou comandos enviados ao Laravel e executados via API do CRM. Não haverá escrita dupla nem sincronização bidireccional irrestrita. Antes da transição, o CRM local permanece operacional; não activar envios externos neste estado. + +Pendentes externos: motor Docker num ambiente Linux de testes; domínio/TLS da homologação; regras de atribuição e SLA; perfis institucionais; finalidade, retenção, transferências e eliminação validadas pelas áreas jurídica e institucional. Nenhum prazo legal é presumido. + +## 3. Arquitectura proposta + +Laravel é o único intermediário entre React e SuiteCRM. Credenciais ficam no backend. Os sistemas não partilham utilizadores de base de dados. Laravel não recebe credenciais MariaDB. A conta técnica OAuth será não administrativa e terá acesso apenas aos módulos necessários. OAuth máquina-a-máquina não é SSO dos utilizadores; manter Fortify nesta fase. + +Reutilizar a outbox transaccional: gravação do pedido, UUID de integração e evento na mesma transacção. O worker resolve o payload mínimo autorizado e chama a API oficial. A resposta ao investidor confirma recepção, nunca sincronização ainda pendente. Webhooks assinados entram pela inbox; não se assume que SuiteCRM emita nativamente o contrato de assinatura proposto. Será necessária uma extensão suportada e testada. + +## 4. Componentes + +```mermaid +flowchart LR + React[React] --> BFF[Laravel BFF] + BFF --> PG[PostgreSQL e PostGIS] + PG --> Outbox[Outbox transaccional] + Outbox --> Worker[Worker de integração] + Worker --> Client[SuiteCrmClient] + Client --> API[API oficial SuiteCRM OAuth2] + API --> CRM[SuiteCRM] + CRM --> MariaDB[MariaDB exclusiva do CRM] + CRM --> Hook[Extensão de eventos assinados] + Hook --> Inbox[Laravel inbox] + Inbox --> Projection[Projecção autorizada e reconciliação] + Projection --> PG +``` + +## 5. Sequência prevista + +```mermaid +sequenceDiagram + actor Investidor + participant Portal as React e Laravel + participant DB as PostgreSQL + participant Worker + participant CRM as API SuiteCRM + Investidor->>Portal: Submeter com chave idempotente + Portal->>Portal: Validar identidade, autorização e finalidade + Portal->>DB: Transacção pedido + identidade UUID + outbox + DB-->>Portal: Commit + Portal-->>Investidor: Referência e estado recebido + Worker->>DB: Reclamar evento com lease + Worker->>CRM: Consultar identidade externa e candidatos + alt Identidade ambígua + Worker->>DB: Aguardar revisão humana + else Identidade resolvida + Worker->>CRM: Criar ou actualizar contacto e organização + Worker->>CRM: Criar lead, relações e tarefa com responsável + Worker->>DB: Confirmar vínculos e resultado + end +``` + +## 6. Responsabilidades dos sistemas + +| Domínio | Sistema principal após transição | Regra | +| ---------------------------------------------------------- | -------------------------------- | ---------------------------------------------------------- | +| Login, sessões, acesso do investidor | Portal | Não copiar credenciais para CRM | +| Organizações, representantes, contactos | SuiteCRM | Portal propõe alterações; CRM valida; cache mínimo | +| Perfil de investimento | Portal | Ligação ao contacto/organização por UUID estável | +| Leads, negociação, qualificação, responsáveis | SuiteCRM | Projecção no portal; revisão humana | +| Campanhas, reuniões, tarefas, casos e aftercare comercial | SuiteCRM | Estado autorizado devolvido ao portal | +| Manifestação submetida e comprovativo | Portal | Conteúdo original preservado; espelho comercial mínimo | +| Projectos, clusters, corredores e oportunidades publicadas | Portal | Réplicas de referência no CRM; sem editar cadastro lá | +| Oportunidade comercial de negociação | SuiteCRM | Não confundir com oportunidade publicada no portal | +| Geometrias e análise GIS | Portal | Não replicar geometrias completas para CRM | +| Consentimento recolhido no portal | Portal | Revogação restringe tratamento; não reactivar via CRM | +| Documentos submetidos | Armazenamento privado do portal | CRM recebe metadados e acesso controlado quando necessário | +| Estado técnico de entrega, conflitos e auditoria | Portal | Nunca tokens, passwords ou payload pessoal nos logs | + +## 7–9. Módulos, campos e mapeamentos — direcção definida + +Usar Accounts para organizações, Contacts para pessoas e relações para representação. Não criar um módulo Investors que duplique esses cadastros; um perfil de investimento distinto pode referenciá-los. Usar Leads, Opportunities, Campaigns, Meetings, Tasks, Cases e Documents quando a semântica coincidir. + +Avaliar módulos personalizados para manifestações, referências de projectos/clusters/corredores e jornadas. Procedimentos regulatórios permanecem no portal; um resumo no CRM só será criado com necessidade demonstrada. Aftercare usa Cases com classificação própria, salvo exigências de permissões/ciclo incompatíveis. O catálogo detalhado e a matriz de 15 colunas ainda serão entregues antes da implementação funcional. + +Campos comuns previstos: UUID externo imutável, sistema de origem, versão, classificação, instante de recolha e código de finalidade. Nomes técnicos, relacionamentos e disponibilidade na API devem ser comprovados na instalação. Índice único de identidade externa deve ser definido por extensão suportada. Email partilhado ou telefone coincidente são candidatos a revisão, não autorização para fusão automática. + +## 10. Endpoints — contrato a desenvolver + +Preservar `/api/v1/interest-requests` durante a migração. O novo contrato de manifestações deverá partilhar o mesmo serviço e registo idempotente; não criar dois fluxos de escrita independentes. Dashboard, consulta, documentos e reuniões exigem utilizador autenticado e propriedade do pedido. Nunca autorizar consulta apenas pela referência IR. + +Reconcile e health detalhado serão restritos à operação autorizada. Webhook exige assinatura do corpo bruto, timestamp, identificador único e protecção contra replay. Não implementar upload de binários supondo suporte genérico da API: validar Documents/DocumentRevisions e o mecanismo oficial da versão instalada. + +## 11. Eventos e filas + +Entrega pelo menos uma vez, com idempotência persistente; não prometer exactly-once distribuído. Evento leva identificadores, versão, correlação e finalidade, evitando cópias de dados pessoais. Reutilizar inbox/outbox e acrescentar leases, tentativas e estado de entrega onde necessário, respeitando os triggers de envelope imutável. + +Retries limitados, backoff exponencial com jitter e respeito por Retry-After. Respostas 401 permitem uma reautenticação; 403/422 exigem intervenção, não repetição cega. Após timeout de criação, pesquisar pelo UUID externo antes de repetir. Conflitos, duplicados ambíguos e falhas definitivas ficam em revisão, com reprocessamento autorizado e auditado. Reconciliação compara identificador, versão e campos permitidos, sem substituir automaticamente o sistema principal. + +Eventos previstos incluem ExpressionOfInterestSubmitted, InvestorRegistered, OrganizationCreated, LeadCreated, LeadQualified, OpportunityCreated, OpportunityStageChanged, MeetingScheduled, FollowUpOverdue, ProjectLinked, ClusterLinked, CorridorLinked e PostInvestmentCaseOpened. Ainda não publicados pela aplicação. + +## 12. Estrutura futura e etapas verificáveis + +O cliente em `app/Integrations/SuiteCrm/` e os testes em `tests/Feature/SuiteCrm/` foram acrescentados na entrega seguinte, descrita em [client.md](client.md). Jobs, eventos, listeners e contratos completos dos domínios ainda estão por implementar. + +1. Decisão e preparação da instalação isolada (esta entrega). +2. Instalação, conta técnica, OAuth, descoberta dos módulos e prova de contrato de API. +3. Catálogo detalhado, campos, mapeamento e endpoints; testes de contrato primeiro. +4. Cliente HTTP, autenticação segura, circuit breaker e testes com respostas simuladas. +5. Outbox/idempotência e jornada completa numa base de homologação. +6. Reconciliação, webhooks, documentos, painéis e restantes domínios. +7. Migração, reconciliação integral, congelamento da escrita comercial local e Go/No-Go. + +## Migração e rollback + +Criar backup e inventário; ensaiar importação pela API numa instalação limpa. Associar cada registo local a um UUID externo antes de migrar relações. Não fundir pessoas automaticamente. Comparar contagens, vínculos, responsáveis e consentimentos; resolver divergências. + +Durante o corte, congelar escrita comercial local, escoar a outbox, reconciliar e só depois activar o CRM como principal. Não eliminar tabelas antigas. Desactivar a integração pára novos envios, mas não desfaz alterações já feitas no CRM. Um rollback após escrita no SuiteCRM exige exportação/reconciliação dessas alterações antes de reabrir o CRM local; restaurar simplesmente um backup perderia trabalho. + +## Go/No-Go e estado da entrega + +NO-GO para produção. Faltam instalação real, OAuth técnico, ligação do cliente aos fluxos, módulos, contratos completos, testes ponta a ponta, validação de segurança/privacidade/Produto e ensaio de recuperação. Os testes do cliente com HTTP simulado não comprovam uma integração real. + +Esta alteração acrescenta documentação e um modelo Docker de homologação. Não altera rotas, migrations, dados ou comportamento do portal. Para desfazer esta preparação basta deixar de usar o directório de implantação; não há migração de dados a reverter. + +## Fontes oficiais + +- [Release 8.10.2](https://github.com/SuiteCRM/SuiteCRM-Core/releases/tag/v8.10.2) +- [Compatibilidade](https://docs.suitecrm.com/8.x/admin/compatibility-matrix/) +- [Instalação](https://docs.suitecrm.com/8.x/admin/installation-guide/downloading-installing/) +- [Servidor web](https://docs.suitecrm.com/8.x/admin/installation-guide/webserver-setup-guide/) +- [Instalador CLI](https://docs.suitecrm.com/8.x/admin/installation-guide/running-the-cli-installer/) +- [OAuth2](https://docs.suitecrm.com/developer/integration-api/v8/authentication/) diff --git a/docs/investmoz/suitecrm/client.md b/docs/investmoz/suitecrm/client.md new file mode 100644 index 00000000..3ac215a8 --- /dev/null +++ b/docs/investmoz/suitecrm/client.md @@ -0,0 +1,75 @@ +# SuiteCrmClient — contrato implementado + +Estado: cliente Laravel implementado e testado com HTTP simulado. Não há SuiteCRM instalado, credenciais criadas, tráfego real ou integração com as manifestações. Não activar apenas por estes testes passarem. + +## Ficheiros desta entrega + +| Ficheiro | Finalidade | +| ------------------------------------------------- | ------------------------------------------------- | +| `app/Integrations/SuiteCrm/SuiteCrmClient.php` | Cliente OAuth2/JSON:API | +| `app/Integrations/SuiteCrm/SuiteCrmException.php` | Erro sanitizado e metadados de recuperação | +| `config/suitecrm.php` | Desactivação por defeito, URLs, módulos e limites | +| `.env.example` | Variáveis backend sem credenciais reais | +| `tests/Feature/SuiteCrm/SuiteCrmClientTest.php` | Testes de contrato local, recuperação e segurança | + +Sem novas dependências Composer, rotas, migrations ou chamadas desde o frontend. A configuração usa o cache database existente; os testes usam array e não precisam de uma base de dados. Em implantação com vários workers, todos devem partilhar o cache e os locks. + +## Contrato HTTP + +A base configurada é a URL HTTPS da aplicação legacy que contém `Api`; o prefixo real será confirmado na instalação. Todos os caminhos abaixo são relativos a essa base. + +| Método Laravel | HTTP | Caminho | Contrato | +| -------------------- | ----- | -------------------------------------------------------- | ------------------------------------------------------------------------------- | +| Autenticação interna | POST | `/Api/access_token` | `grant_type=client_credentials`, client ID/secret; resposta Bearer com validade | +| `find` | GET | `/Api/V8/module/{module}/{id}` | Registo autorizado | +| `search` | GET | `/Api/V8/module/{module}` | Filtros exactos combinados por AND; página >=1; tamanho 1–100 | +| `create` | POST | `/Api/V8/module` | Envelope `data.type` e `data.attributes` | +| `update` | PATCH | `/Api/V8/module` | Envelope com tipo, ID e atributos | +| `relate` | POST | `/Api/V8/module/{module}/{id}/relationships/{linkField}` | Tipo e ID do registo relacionado | +| `fields` | GET | `/Api/V8/meta/fields/{module}` | Metadados para validar o mapeamento | + +O cliente devolve o documento JSON, preservando paginação. Não segue links de paginação devolvidos pelo servidor; o consumidor pede explicitamente a página seguinte. Módulos são limitados à allowlist de configuração; nomes e IDs não aceitam separadores de caminho. Métodos não são endpoints públicos e não substituem autorização de negócio, validação dos atributos ou permissões da conta técnica. + +Os formatos foram confrontados com `public/legacy/Api/V8/Config/routes.php` no pacote oficial SuiteCRM 8.10.2 de SHA-256 `ac4693d10ea2d6cd20b3256098781d134599d88878fb24e8dc127038d624f5a8`. Os testes são contratos do consumidor com respostas simuladas; ainda falta provar compatibilidade contra o servidor. + +## Tokens e segredos + +HTTPS e validação do certificado são obrigatórios; redirects são recusados. A URL não pode conter credenciais, query ou fragmento. Tokens são cifrados com a chave Laravel antes de entrar no cache e expiram antecipadamente. Obtenção e invalidação usam locks. Se outra chamada já renovou o token, um 401 atrasado não deve apagar o token novo. + +Client credentials obtém novo token na expiração; não presume refresh token. Um 401 da API permite uma renovação e repetição. O segundo 401 termina a operação. Alterar o segredo muda o namespace do cache; reiniciar workers/reconstruir o cache de configuração durante rotação. Revogar o cliente antigo no CRM; a mudança local não o revoga remotamente. + +O segredo deve ser injectado no ambiente do backend por mecanismo privado. Não usar variáveis `VITE_`. O `.env` actual não foi alterado. Não imprimir configuração, payloads ou respostas de autenticação no diagnóstico. + +## Retries, circuit breaker e ambiguidade + +- Consultas: até duas repetições para falhas de rede, 429 e 5xx, com backoff exponencial e jitter. +- Retry-After numérico/data é respeitado. Atrasos superiores a cinco segundos são devolvidos ao futuro job, sem bloquear o processo; valor limitado a um dia. +- Criação, actualização e relação: não repetir automaticamente após timeout ou 5xx. `outcomeUnknown=true` exige consultar a identidade externa/reconciliar antes de reenviar. +- 403, 404, 409 e 422 são erros permanentes neste nível; a lógica de negócio decide a recuperação. +- Cinco operações com falha transitória abrem o circuito durante 60 segundos. O contador usa lock. Após o intervalo, operações voltam a ser permitidas; ainda não há controlo de uma única sonda half-open. +- Sucesso com JSON inválido é rejeitado; numa escrita, é considerado ambíguo. + +`retryable` indica natureza transitória, não autorização para repetir uma escrita. O futuro processador da outbox deve respeitar também `outcomeUnknown` e `retryAfter`. O cliente isolado não implementa idempotência distribuída nem fusão de contactos. + +## Observabilidade + +Cada tentativa produz `suitecrm.http` com operação, código HTTP e duração em milissegundos. Não inclui URL, IDs, corpos, atributos ou cabeçalhos. Excepções usam razões enumeradas e não encadeiam a excepção HTTP original. Há atributos SensitiveParameter nos argumentos com dados pessoais/segredos. + +Esses eventos permitem agregar taxa de falhas/latência; ainda não há exporter, dashboard ou alertas instalados. Instrumentação externa que capture HTTP (debuggers/APM) deve ser configurada para excluir tokens e payloads antes de activar a integração. + +## Verificação e limites + +15 testes, 62 asserções, sem rede real: desactivação, HTTPS, tokens cifrados, reutilização, expiração, rotação, 401 limitado, formato JSON:API, retry de leitura, escrita ambígua, Retry-After, circuito, módulos/IDs/paginação, sanitização e resposta inválida. PHPStan passou sem erros. + +A suite completa também passou: 85 testes e 612 asserções. Pint, formatação e lint passaram. Não foi necessário alterar ou testar o frontend visual, pois esta entrega não o modifica. + +Ainda faltam: testes reais e concorrência entre processos; configuração OAuth; RBAC no CRM; documentos binários; outbox/inbox e jobs; reconciliação; métricas/alertas; relação com consentimento e domínios do portal. Metadados de Documents podem ser enviados por `create` após validação do esquema; não existe upload binário genérico implementado. + +## Rollback + +Manter `SUITECRM_ENABLED=false`. Como nenhum fluxo do portal chama o cliente, esta entrega não altera dados comerciais. Ao remover os ficheiros, retirar também a configuração e variáveis de exemplo. Se o cliente tiver sido activado numa instalação futura, desactivar envios e revogar o OAuth; não considerar isso reversão de registos já escritos no CRM. + +## Referências + +- [Endpoints oficiais](https://docs.suitecrm.com/developer/integration-api/v8/endpoints/) +- [Autenticação oficial](https://docs.suitecrm.com/developer/integration-api/v8/authentication/) diff --git a/docs/investmoz/suitecrm/outbox.md b/docs/investmoz/suitecrm/outbox.md new file mode 100644 index 00000000..8534a59c --- /dev/null +++ b/docs/investmoz/suitecrm/outbox.md @@ -0,0 +1,44 @@ +# Recepção idempotente e outbox de manifestações + +Esta entrega captura eventos de domínio; **não envia dados ao SuiteCRM**. Não depende de `SUITECRM_ENABLED`: a captura transaccional preserva eventos enquanto a integração externa está desactivada. Não executar uma futura carga histórica sem reconciliar estes eventos pendentes. + +## Contrato implementado + +`POST /api/v1/interest-requests` mantém o payload actual e aceita `Idempotency-Key` com UUID aleatório. A chave identifica uma intenção de submissão, não a pessoa. Não derivar a chave de email ou NUIT. + +- Primeira submissão válida: 201 com referência e estado de recepção; pedido, recibo e evento confirmados na mesma transacção. +- Mesma chave e payload validado equivalente: mesma confirmação 201; sem novo pedido, notificação ou evento. O estado retornado é o da recepção, não uma consulta do estado comercial actual. +- Mesma chave com dados diferentes: 409, sem alterações. +- Chave inválida/vazia ou campos inválidos: 422. Falha técnica: 500 sanitizado. Os limites de pedidos existentes mantêm-se, incluindo nas repetições. +- Sem chave: compatibilidade com clientes antigos; cada envio é uma nova manifestação, sem garantia de deduplicação de reenvio. + +Após retirar uma oportunidade, um pedido previamente aceite pode ser confirmado novamente com a sua chave e payload; novas submissões continuam rejeitadas. As restantes validações, incluindo email/consentimento, são aplicadas antes de devolver a confirmação. + +O formulário React gera um UUID e reutiliza-o após falha enquanto os dados e a instância do formulário se mantiverem. Alterar dados cria outra intenção/chave. Não persiste payload pessoal em localStorage/sessionStorage. Recarregar, abandonar a página ou abrir outra aba perde esta chave; persistência segura entre sessões ainda não está implementada. + +## Persistência e atomicidade + +Migration `2026_09_25_120000_add_interest_submission_receipts` acrescenta UUID de integração opcional ao pedido e tabela de recibos com hash da chave, HMAC do payload e FK do pedido. Pedidos históricos não são automaticamente migrados. A chave primária única e advisory lock transaccional PostgreSQL serializam a mesma chave entre processos. + +O HMAC usa APP_KEY. Preservar a chave durante esta fase; uma rotação precisa de estratégia de versões/revalidação dos recibos antes de ser aplicada. Recibos não expiram automaticamente, para não reabrir a possibilidade de duplicar pedidos; a política de retenção deve ser definida juntamente com pedidos e auditoria. + +`InterestSubmission` envolve a criação, a notificação em database queue, o UUID, a outbox e o recibo numa transacção. Qualquer falha reverte o conjunto. Não há HTTP externo dentro da transacção. + +O evento `ExpressionOfInterestSubmitted` usa `source_system=portal-intake`, agregado `interest_request`, UUID de integração, estado `pending` e classificação `confidential`. O payload contém apenas `schema_version=1` e `interest_id` (UUID). + +Email, telefone, mensagem, IP, token e consentimento integral não são copiados para o evento. O futuro consumidor terá de consultar o pedido e revalidar finalidade, consentimento e permissão de tratamento antes de montar um payload mínimo para o CRM. Referência IR e UUID não conferem autorização de acesso. + +## Alterações e verificação + +- Criados: migration, `InterestSubmission`, `IdempotencyConflict` e `InterestSubmissionTest`. +- Alterados: controller e validação de pedidos, formulário React e documentação de progresso. +- Dependências: PostgreSQL existente, outbox e triggers existentes, APP_KEY preservada e database queue para notificações. +- Testes cobrem repetição, conflito, compatibilidade, evento sem payload pessoal, falha na outbox com rollback de pedido/notificação, oportunidade retirada e protecção de rollback do esquema. +- Navegador Edge: resposta perdida após gravação seguida de reenvio com a mesma chave e confirmação original. +- A serialização entre processos foi implementada com locks PostgreSQL; ainda não foi executado teste de carga concorrente distribuída. + +## Rollback e pendências + +Antes de receber pedidos, é possível reverter esta migration numa base descartável e reaplicá-la. Depois de existirem identidades/recibos, `down()` recusa a remoção para não perder os vínculos com a outbox. Preferir reverter o código conservando o esquema; reconciliar eventos e dados antes de qualquer remoção aprovada. + +Pendentes: dispatcher com leases, workers SuiteCRM, identidade externa única no CRM, recuperação de escritas ambíguas, dead-letter queue, reprocessamento autorizado, monitoria e reconciliação. Nenhum evento está marcado como publicado nesta entrega. diff --git a/docs/investmoz/table-catalog.csv b/docs/investmoz/table-catalog.csv new file mode 100644 index 00000000..31c88149 --- /dev/null +++ b/docs/investmoz/table-catalog.csv @@ -0,0 +1,60 @@ +Domínio,Esquema,Tabela,Finalidade,Chave primária,Dependências,Migration +Aplicação existente,public,users,Autenticação Laravel/Fortify,id,,database/migrations/0001_01_01_000000_create_users_table.php +Aplicação existente,public,password_reset_tokens,Recuperação de palavra-passe,email,,database/migrations/0001_01_01_000000_create_users_table.php +Aplicação existente,public,sessions,Sessões Laravel,id,,database/migrations/0001_01_01_000000_create_users_table.php +Aplicação existente,public,cache,Cache Laravel,key,,database/migrations/0001_01_01_000001_create_cache_table.php +Aplicação existente,public,cache_locks,Bloqueios de cache,key,,database/migrations/0001_01_01_000001_create_cache_table.php +Aplicação existente,public,jobs,Fila Laravel,id,,database/migrations/0001_01_01_000002_create_jobs_table.php +Aplicação existente,public,job_batches,Lotes de jobs,id,,database/migrations/0001_01_01_000002_create_jobs_table.php +Aplicação existente,public,failed_jobs,Falhas de jobs,id,,database/migrations/0001_01_01_000002_create_jobs_table.php +Aplicação existente,public,passkeys,Credenciais passkey,id,users.id,database/migrations/2024_01_01_000000_create_passkeys_table.php +Aplicação existente,public,sectors,Sectores do catálogo público,id,,database/migrations/2026_01_01_000001_create_sectors_table.php +Aplicação existente,public,opportunities,Catálogo público de oportunidades,id,sectors.id,database/migrations/2026_01_01_000002_create_opportunities_table.php +Aplicação existente,public,interest_requests,Manifestações de interesse,id,opportunities.id; users.id,database/migrations/2026_01_01_000003_create_interest_requests_table.php +Identidade,public,organizations,"Registar organizações institucionais, empresariais e educativas",id,,database/migrations/2026_09_24_001000_investmoz_identity.php +Identidade,public,memberships,Associar utilizadores a organizações e ao respectivo papel,id,organizations.id; users.id,database/migrations/2026_09_24_001000_investmoz_identity.php +Identidade,public,consents,Registar concessão e retirada de consentimentos por finalidade,id,users.id,database/migrations/2026_09_24_001000_investmoz_identity.php +Conteúdos,public,documents,"Catalogar ficheiros, classificação e estado de quarentena",id,,database/migrations/2026_09_24_002000_investmoz_content.php +Geografia,public,geo_locations,Representar a hierarquia territorial,id,geo_locations.id,database/migrations/2026_09_24_003000_investmoz_geography.php +Geografia,public,geo_geometries,Armazenar geometrias válidas das localizações,id,geo_locations.id,database/migrations/2026_09_24_003000_investmoz_geography.php +Projectos,public,projects,Registar projectos de investimento e orçamento,id,,database/migrations/2026_09_24_004000_investmoz_projects.php +Projectos,public,project_locations,Associar projectos a localizações por função,id,geo_locations.id; projects.id,database/migrations/2026_09_24_004000_investmoz_projects.php +Clusters e cadeias,public,economic_clusters,Registar clusters económicos,id,,database/migrations/2026_09_24_005000_investmoz_clusters.php +Clusters e cadeias,public,value_chains,Registar cadeias de valor de cada cluster,id,economic_clusters.id,database/migrations/2026_09_24_005000_investmoz_clusters.php +Clusters e cadeias,public,value_chain_links,Associar cadeias por tipo de ligação,id,value_chains.id,database/migrations/2026_09_24_005000_investmoz_clusters.php +Clusters e cadeias,public,cluster_projects,Associar projectos a clusters,id,economic_clusters.id; projects.id,database/migrations/2026_09_24_005000_investmoz_clusters.php +Clusters e cadeias,public,anchor_project_links,Justificar projectos âncora de clusters,id,economic_clusters.id; projects.id,database/migrations/2026_09_24_005000_investmoz_clusters.php +Clusters e cadeias,public,complementary_project_links,Relacionar projectos âncora e complementares,id,projects.id,database/migrations/2026_09_24_005000_investmoz_clusters.php +Corredores,public,development_corridors,Registar corredores de desenvolvimento,id,,database/migrations/2026_09_24_006000_investmoz_corridors.php +Corredores,public,corridor_locations,Ordenar localizações de corredores,id,development_corridors.id; geo_locations.id,database/migrations/2026_09_24_006000_investmoz_corridors.php +Corredores,public,corridor_projects,Associar corredores e projectos,id,development_corridors.id; projects.id,database/migrations/2026_09_24_006000_investmoz_corridors.php +Corredores,public,corridor_clusters,Associar corredores e clusters,id,development_corridors.id; economic_clusters.id,database/migrations/2026_09_24_006000_investmoz_corridors.php +CRM,public,investors,Registar investidores e o seu enquadramento institucional,id,organizations.id,database/migrations/2026_09_24_007000_investmoz_crm.php +CRM,public,contacts,Registar contactos partilháveis entre investidores,id,,database/migrations/2026_09_24_007000_investmoz_crm.php +CRM,public,investor_contacts,Associar investidores e contactos com indicação de contacto principal,"investor_id, contact_id",contacts.id; investors.id,database/migrations/2026_09_24_007000_investmoz_crm.php +CRM,public,leads,Qualificar potenciais negócios no CRM,id,investors.id; users.id,database/migrations/2026_09_24_007000_investmoz_crm.php +CRM,public,crm_opportunities,"Gerir negociações CRM, separadas do catálogo público opportunities",id,leads.id; projects.id; users.id,database/migrations/2026_09_24_007000_investmoz_crm.php +CRM,public,activities,Registar interacções CRM,id,crm_opportunities.id; investors.id; users.id,database/migrations/2026_09_24_007000_investmoz_crm.php +CRM,public,tasks,Acompanhar tarefas e prazos CRM,id,crm_opportunities.id; users.id,database/migrations/2026_09_24_007000_investmoz_crm.php +Jornada e aftercare,public,investor_journeys,Acompanhar jornadas dos investidores,id,crm_opportunities.id; investors.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +Jornada e aftercare,public,journey_steps,Ordenar etapas de uma jornada,id,investor_journeys.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +Jornada e aftercare,public,submissions,Registar submissões nas etapas,id,journey_steps.id; users.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +Jornada e aftercare,public,aftercare_plans,Acompanhar planos de pós-investimento,id,investors.id; projects.id; users.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +Jornada e aftercare,public,constraints,Registar constrangimentos de pós-investimento,id,aftercare_plans.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +Jornada e aftercare,public,escalations,Registar escalonamentos de constrangimentos,id,constraints.id; users.id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php +Educação,public,skills,Catalogar competências,id,,database/migrations/2026_09_24_009000_investmoz_education.php +Educação,public,occupations,Catalogar ocupações,id,,database/migrations/2026_09_24_009000_investmoz_education.php +Educação,public,training_needs,Quantificar necessidades de formação,id,occupations.id; projects.id; skills.id,database/migrations/2026_09_24_009000_investmoz_education.php +Educação,public,training_providers,Registar prestadores de formação,id,organizations.id,database/migrations/2026_09_24_009000_investmoz_education.php +Educação,public,courses,Catalogar cursos por prestador,id,training_providers.id,database/migrations/2026_09_24_009000_investmoz_education.php +Educação,public,education_opportunities,Publicar ofertas educativas com datas e capacidade,id,courses.id; geo_locations.id,database/migrations/2026_09_24_009000_investmoz_education.php +Conteúdos — ligações tipadas,public,project_documents,Associar documentos a projects com integridade referencial,"project_id, document_id",documents.id; projects.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +Conteúdos — ligações tipadas,public,investor_documents,Associar documentos a investors com integridade referencial,"investor_id, document_id",documents.id; investors.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +Conteúdos — ligações tipadas,public,journey_documents,Associar documentos a investor_journeys com integridade referencial,"journey_id, document_id",documents.id; investor_journeys.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +Conteúdos — ligações tipadas,public,aftercare_documents,Associar documentos a aftercare_plans com integridade referencial,"aftercare_plan_id, document_id",aftercare_plans.id; documents.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +Conteúdos — ligações tipadas,public,education_documents,Associar documentos a education_opportunities com integridade referencial,"education_opportunity_id, document_id",documents.id; education_opportunities.id,database/migrations/2026_09_24_009500_investmoz_document_links.php +Governação e auditoria,public,audit_events,"Preservar eventos de auditoria sem actualização, eliminação ou truncagem",id,users.id,database/migrations/2026_09_24_010000_investmoz_governance_ai.php +Integrações e eventos,public,outbox_events,Registar eventos de saída e estado de entrega,id,,database/migrations/2026_09_24_011000_investmoz_integrations.php +Integrações e eventos,public,inbox_events,Deduplicar mensagens de entrada e acompanhar processamento,id,,database/migrations/2026_09_24_011000_investmoz_integrations.php +Integrações e eventos,public,sync_runs,Acompanhar execuções de integração,id,,database/migrations/2026_09_24_011000_investmoz_integrations.php +Integrações e eventos,public,reconciliation_issues,Registar divergências de reconciliação,id,sync_runs.id,database/migrations/2026_09_24_011000_investmoz_integrations.php diff --git a/docs/investmoz/traceability.csv b/docs/investmoz/traceability.csv new file mode 100644 index 00000000..0a473cdc --- /dev/null +++ b/docs/investmoz/traceability.csv @@ -0,0 +1,685 @@ +Domínio,Tabela,Campo,Migration,Requisito/Fonte,Decisão +Aplicação existente,users,id,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,users,name,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,users,email,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,users,email_verified_at,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,users,password,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,users,remember_token,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,users,created_at,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,users,updated_at,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,users,two_factor_secret,database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,users,two_factor_recovery_codes,database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,users,two_factor_confirmed_at,database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,password_reset_tokens,email,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,password_reset_tokens,token,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,password_reset_tokens,created_at,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sessions,id,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sessions,user_id,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sessions,ip_address,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sessions,user_agent,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sessions,payload,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sessions,last_activity,database/migrations/0001_01_01_000000_create_users_table.php,Migration instalada,COMPAT-01 +Aplicação existente,cache,key,database/migrations/0001_01_01_000001_create_cache_table.php,Migration instalada,COMPAT-01 +Aplicação existente,cache,value,database/migrations/0001_01_01_000001_create_cache_table.php,Migration instalada,COMPAT-01 +Aplicação existente,cache,expiration,database/migrations/0001_01_01_000001_create_cache_table.php,Migration instalada,COMPAT-01 +Aplicação existente,cache_locks,key,database/migrations/0001_01_01_000001_create_cache_table.php,Migration instalada,COMPAT-01 +Aplicação existente,cache_locks,owner,database/migrations/0001_01_01_000001_create_cache_table.php,Migration instalada,COMPAT-01 +Aplicação existente,cache_locks,expiration,database/migrations/0001_01_01_000001_create_cache_table.php,Migration instalada,COMPAT-01 +Aplicação existente,jobs,id,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,jobs,queue,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,jobs,payload,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,jobs,attempts,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,jobs,reserved_at,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,jobs,available_at,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,jobs,created_at,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,job_batches,id,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,job_batches,name,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,job_batches,total_jobs,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,job_batches,pending_jobs,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,job_batches,failed_jobs,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,job_batches,failed_job_ids,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,job_batches,options,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,job_batches,cancelled_at,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,job_batches,created_at,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,job_batches,finished_at,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,failed_jobs,id,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,failed_jobs,uuid,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,failed_jobs,connection,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,failed_jobs,queue,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,failed_jobs,payload,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,failed_jobs,exception,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,failed_jobs,failed_at,database/migrations/0001_01_01_000002_create_jobs_table.php,Migration instalada,COMPAT-01 +Aplicação existente,passkeys,id,database/migrations/2024_01_01_000000_create_passkeys_table.php,Migration instalada,COMPAT-01 +Aplicação existente,passkeys,user_id,database/migrations/2024_01_01_000000_create_passkeys_table.php,Migration instalada,COMPAT-01 +Aplicação existente,passkeys,name,database/migrations/2024_01_01_000000_create_passkeys_table.php,Migration instalada,COMPAT-01 +Aplicação existente,passkeys,credential_id,database/migrations/2024_01_01_000000_create_passkeys_table.php,Migration instalada,COMPAT-01 +Aplicação existente,passkeys,credential,database/migrations/2024_01_01_000000_create_passkeys_table.php,Migration instalada,COMPAT-01 +Aplicação existente,passkeys,last_used_at,database/migrations/2024_01_01_000000_create_passkeys_table.php,Migration instalada,COMPAT-01 +Aplicação existente,passkeys,created_at,database/migrations/2024_01_01_000000_create_passkeys_table.php,Migration instalada,COMPAT-01 +Aplicação existente,passkeys,updated_at,database/migrations/2024_01_01_000000_create_passkeys_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sectors,id,database/migrations/2026_01_01_000001_create_sectors_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sectors,name,database/migrations/2026_01_01_000001_create_sectors_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sectors,slug,database/migrations/2026_01_01_000001_create_sectors_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sectors,short_description,database/migrations/2026_01_01_000001_create_sectors_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sectors,description,database/migrations/2026_01_01_000001_create_sectors_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sectors,icon,database/migrations/2026_01_01_000001_create_sectors_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sectors,sort_order,database/migrations/2026_01_01_000001_create_sectors_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sectors,is_active,database/migrations/2026_01_01_000001_create_sectors_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sectors,published_at,database/migrations/2026_01_01_000001_create_sectors_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sectors,created_at,database/migrations/2026_01_01_000001_create_sectors_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sectors,updated_at,database/migrations/2026_01_01_000001_create_sectors_table.php,Migration instalada,COMPAT-01 +Aplicação existente,sectors,deleted_at,database/migrations/2026_01_01_000001_create_sectors_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,id,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,sector_id,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,reference,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,title,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,slug,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,summary,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,description,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,province,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,district,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,promoter_name,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,promoter_type,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,maturity_level,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,maturity_label,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,estimated_investment_amount,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,estimated_investment_currency,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,financing_modality,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,expected_impact,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,status,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,is_featured,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,published_at,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,last_validated_at,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,created_at,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,updated_at,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,opportunities,deleted_at,database/migrations/2026_01_01_000002_create_opportunities_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,id,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,opportunity_id,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,reference,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,full_name,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,email,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,phone,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,investor_type,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,country,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,sector_interest,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,opportunity_reference,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,investment_range,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,message,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,privacy_consent,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,consented_at,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,status,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,source_page,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,ip_address,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,user_agent,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,metadata,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,reviewed_at,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,reviewed_by,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,created_at,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,updated_at,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Aplicação existente,interest_requests,deleted_at,database/migrations/2026_01_01_000003_create_interest_requests_table.php,Migration instalada,COMPAT-01 +Identidade,organizations,id,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,organizations,source_system,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,organizations,external_id,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,organizations,version_no,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,organizations,data_classification,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,organizations,correlation_id,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,organizations,created_at,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,organizations,updated_at,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,organizations,deleted_at,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,organizations,name,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,organizations,organization_type,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,memberships,id,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,memberships,source_system,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,memberships,external_id,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,memberships,version_no,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,memberships,data_classification,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,memberships,correlation_id,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,memberships,created_at,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,memberships,updated_at,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,memberships,deleted_at,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,memberships,user_id,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,memberships,organization_id,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,memberships,role,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,id,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,source_system,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,external_id,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,version_no,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,data_classification,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,correlation_id,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,created_at,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,updated_at,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,deleted_at,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,user_id,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,subject_ref,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,purpose,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,status,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,granted_at,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Identidade,consents,withdrawn_at,database/migrations/2026_09_24_001000_investmoz_identity.php,A.26; A.31.2,RECON-01 +Conteúdos,documents,id,database/migrations/2026_09_24_002000_investmoz_content.php,A.26; A.31.2,RECON-01 +Conteúdos,documents,source_system,database/migrations/2026_09_24_002000_investmoz_content.php,A.26; A.31.2,RECON-01 +Conteúdos,documents,external_id,database/migrations/2026_09_24_002000_investmoz_content.php,A.26; A.31.2,RECON-01 +Conteúdos,documents,version_no,database/migrations/2026_09_24_002000_investmoz_content.php,A.26; A.31.2,RECON-01 +Conteúdos,documents,data_classification,database/migrations/2026_09_24_002000_investmoz_content.php,A.26; A.31.2,RECON-01 +Conteúdos,documents,correlation_id,database/migrations/2026_09_24_002000_investmoz_content.php,A.26; A.31.2,RECON-01 +Conteúdos,documents,created_at,database/migrations/2026_09_24_002000_investmoz_content.php,A.26; A.31.2,RECON-01 +Conteúdos,documents,updated_at,database/migrations/2026_09_24_002000_investmoz_content.php,A.26; A.31.2,RECON-01 +Conteúdos,documents,deleted_at,database/migrations/2026_09_24_002000_investmoz_content.php,A.26; A.31.2,RECON-01 +Conteúdos,documents,file_name,database/migrations/2026_09_24_002000_investmoz_content.php,A.26; A.31.2,RECON-01 +Conteúdos,documents,media_type,database/migrations/2026_09_24_002000_investmoz_content.php,A.26; A.31.2,RECON-01 +Conteúdos,documents,storage_key,database/migrations/2026_09_24_002000_investmoz_content.php,A.26; A.31.2,RECON-01 +Conteúdos,documents,sha256,database/migrations/2026_09_24_002000_investmoz_content.php,A.26; A.31.2,RECON-01 +Conteúdos,documents,status,database/migrations/2026_09_24_002000_investmoz_content.php,A.26; A.31.2,RECON-01 +Geografia,geo_locations,id,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_locations,source_system,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_locations,external_id,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_locations,version_no,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_locations,data_classification,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_locations,correlation_id,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_locations,created_at,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_locations,updated_at,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_locations,deleted_at,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_locations,name,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_locations,location_type,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_locations,parent_id,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_locations,code,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_geometries,id,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_geometries,source_system,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_geometries,external_id,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_geometries,version_no,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_geometries,data_classification,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_geometries,correlation_id,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_geometries,created_at,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_geometries,updated_at,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_geometries,deleted_at,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_geometries,geo_location_id,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_geometries,geometry_type,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_geometries,geom,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Geografia,geo_geometries,properties,database/migrations/2026_09_24_003000_investmoz_geography.php,A.25; A.31.2,RECON-01 +Projectos,projects,id,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,source_system,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,external_id,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,version_no,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,data_classification,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,correlation_id,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,created_at,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,updated_at,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,deleted_at,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,code,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,name,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,status,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,description,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,budget_amount,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,budget_currency,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,start_date,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,end_date,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,projects,metadata,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,project_locations,id,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,project_locations,source_system,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,project_locations,external_id,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,project_locations,version_no,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,project_locations,data_classification,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,project_locations,correlation_id,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,project_locations,created_at,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,project_locations,updated_at,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,project_locations,deleted_at,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,project_locations,project_id,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,project_locations,geo_location_id,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Projectos,project_locations,role,database/migrations/2026_09_24_004000_investmoz_projects.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,economic_clusters,id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,economic_clusters,source_system,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,economic_clusters,external_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,economic_clusters,version_no,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,economic_clusters,data_classification,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,economic_clusters,correlation_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,economic_clusters,created_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,economic_clusters,updated_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,economic_clusters,deleted_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,economic_clusters,code,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,economic_clusters,name,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,economic_clusters,sector,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,economic_clusters,status,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,economic_clusters,metadata,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chains,id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chains,source_system,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chains,external_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chains,version_no,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chains,data_classification,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chains,correlation_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chains,created_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chains,updated_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chains,deleted_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chains,cluster_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chains,code,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chains,name,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chains,stage,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chain_links,id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chain_links,source_system,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chain_links,external_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chain_links,version_no,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chain_links,data_classification,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chain_links,correlation_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chain_links,created_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chain_links,updated_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chain_links,deleted_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chain_links,from_value_chain_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chain_links,to_value_chain_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,value_chain_links,link_type,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,cluster_projects,id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,cluster_projects,source_system,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,cluster_projects,external_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,cluster_projects,version_no,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,cluster_projects,data_classification,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,cluster_projects,correlation_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,cluster_projects,created_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,cluster_projects,updated_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,cluster_projects,deleted_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,cluster_projects,cluster_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,cluster_projects,project_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,cluster_projects,role,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,anchor_project_links,id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,anchor_project_links,source_system,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,anchor_project_links,external_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,anchor_project_links,version_no,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,anchor_project_links,data_classification,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,anchor_project_links,correlation_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,anchor_project_links,created_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,anchor_project_links,updated_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,anchor_project_links,deleted_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,anchor_project_links,anchor_project_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,anchor_project_links,cluster_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,anchor_project_links,rationale,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,complementary_project_links,id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,complementary_project_links,source_system,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,complementary_project_links,external_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,complementary_project_links,version_no,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,complementary_project_links,data_classification,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,complementary_project_links,correlation_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,complementary_project_links,created_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,complementary_project_links,updated_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,complementary_project_links,deleted_at,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,complementary_project_links,anchor_project_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,complementary_project_links,complementary_project_id,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Clusters e cadeias,complementary_project_links,relation_type,database/migrations/2026_09_24_005000_investmoz_clusters.php,A.25; A.31.2,RECON-01 +Corredores,development_corridors,id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,development_corridors,source_system,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,development_corridors,external_id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,development_corridors,version_no,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,development_corridors,data_classification,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,development_corridors,correlation_id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,development_corridors,created_at,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,development_corridors,updated_at,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,development_corridors,deleted_at,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,development_corridors,code,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,development_corridors,name,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,development_corridors,status,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,development_corridors,description,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_locations,id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_locations,source_system,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_locations,external_id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_locations,version_no,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_locations,data_classification,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_locations,correlation_id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_locations,created_at,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_locations,updated_at,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_locations,deleted_at,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_locations,corridor_id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_locations,geo_location_id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_locations,sequence_no,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_projects,id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_projects,source_system,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_projects,external_id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_projects,version_no,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_projects,data_classification,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_projects,correlation_id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_projects,created_at,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_projects,updated_at,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_projects,deleted_at,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_projects,corridor_id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_projects,project_id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_clusters,id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_clusters,source_system,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_clusters,external_id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_clusters,version_no,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_clusters,data_classification,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_clusters,correlation_id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_clusters,created_at,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_clusters,updated_at,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_clusters,deleted_at,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_clusters,corridor_id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +Corredores,corridor_clusters,cluster_id,database/migrations/2026_09_24_006000_investmoz_corridors.php,A.25; A.31.2,RECON-01 +CRM,investors,id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investors,source_system,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investors,external_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investors,version_no,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investors,data_classification,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investors,correlation_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investors,created_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investors,updated_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investors,deleted_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investors,organization_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investors,legal_name,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investors,investor_type,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investors,status,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,contacts,id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,contacts,source_system,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,contacts,external_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,contacts,version_no,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,contacts,data_classification,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,contacts,correlation_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,contacts,created_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,contacts,updated_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,contacts,deleted_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,contacts,full_name,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,contacts,email,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,contacts,phone,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investor_contacts,investor_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investor_contacts,contact_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investor_contacts,is_primary,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investor_contacts,created_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,investor_contacts,updated_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,source_system,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,external_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,version_no,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,data_classification,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,correlation_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,created_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,updated_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,deleted_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,investor_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,owner_user_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,title,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,status,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,estimated_amount,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,leads,currency,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,source_system,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,external_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,version_no,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,data_classification,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,correlation_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,created_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,updated_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,deleted_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,lead_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,project_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,owner_user_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,stage,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,amount,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,currency,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,crm_opportunities,probability,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,source_system,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,external_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,version_no,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,data_classification,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,correlation_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,created_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,updated_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,deleted_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,investor_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,opportunity_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,actor_user_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,activity_type,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,occurred_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,activities,summary,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,tasks,id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,tasks,source_system,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,tasks,external_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,tasks,version_no,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,tasks,data_classification,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,tasks,correlation_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,tasks,created_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,tasks,updated_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,tasks,deleted_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,tasks,assignee_user_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,tasks,opportunity_id,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,tasks,title,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,tasks,status,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +CRM,tasks,due_at,database/migrations/2026_09_24_007000_investmoz_crm.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,investor_journeys,id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,investor_journeys,source_system,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,investor_journeys,external_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,investor_journeys,version_no,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,investor_journeys,data_classification,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,investor_journeys,correlation_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,investor_journeys,created_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,investor_journeys,updated_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,investor_journeys,deleted_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,investor_journeys,investor_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,investor_journeys,opportunity_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,investor_journeys,status,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,investor_journeys,started_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,journey_steps,id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,journey_steps,source_system,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,journey_steps,external_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,journey_steps,version_no,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,journey_steps,data_classification,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,journey_steps,correlation_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,journey_steps,created_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,journey_steps,updated_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,journey_steps,deleted_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,journey_steps,journey_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,journey_steps,step_code,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,journey_steps,sequence_no,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,journey_steps,status,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,submissions,id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,submissions,source_system,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,submissions,external_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,submissions,version_no,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,submissions,data_classification,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,submissions,correlation_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,submissions,created_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,submissions,updated_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,submissions,deleted_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,submissions,journey_step_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,submissions,submitted_by,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,submissions,status,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,submissions,payload,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,submissions,submitted_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,aftercare_plans,id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,aftercare_plans,source_system,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,aftercare_plans,external_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,aftercare_plans,version_no,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,aftercare_plans,data_classification,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,aftercare_plans,correlation_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,aftercare_plans,created_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,aftercare_plans,updated_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,aftercare_plans,deleted_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,aftercare_plans,investor_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,aftercare_plans,project_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,aftercare_plans,owner_user_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,aftercare_plans,status,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,aftercare_plans,review_date,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,constraints,id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,constraints,source_system,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,constraints,external_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,constraints,version_no,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,constraints,data_classification,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,constraints,correlation_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,constraints,created_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,constraints,updated_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,constraints,deleted_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,constraints,aftercare_plan_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,constraints,category,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,constraints,description,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,constraints,severity,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,constraints,status,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,escalations,id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,escalations,source_system,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,escalations,external_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,escalations,version_no,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,escalations,data_classification,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,escalations,correlation_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,escalations,created_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,escalations,updated_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,escalations,deleted_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,escalations,constraint_id,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,escalations,escalated_by,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,escalations,level,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,escalations,status,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Jornada e aftercare,escalations,escalated_at,database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php,A.26; A.31.2,RECON-01 +Educação,skills,id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,skills,source_system,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,skills,external_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,skills,version_no,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,skills,data_classification,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,skills,correlation_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,skills,created_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,skills,updated_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,skills,deleted_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,skills,code,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,skills,name,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,occupations,id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,occupations,source_system,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,occupations,external_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,occupations,version_no,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,occupations,data_classification,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,occupations,correlation_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,occupations,created_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,occupations,updated_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,occupations,deleted_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,occupations,code,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,occupations,name,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_needs,id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_needs,source_system,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_needs,external_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_needs,version_no,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_needs,data_classification,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_needs,correlation_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_needs,created_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_needs,updated_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_needs,deleted_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_needs,project_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_needs,occupation_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_needs,skill_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_needs,quantity,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_needs,priority,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_providers,id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_providers,source_system,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_providers,external_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_providers,version_no,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_providers,data_classification,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_providers,correlation_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_providers,created_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_providers,updated_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_providers,deleted_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_providers,organization_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_providers,name,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,training_providers,status,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,courses,id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,courses,source_system,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,courses,external_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,courses,version_no,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,courses,data_classification,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,courses,correlation_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,courses,created_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,courses,updated_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,courses,deleted_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,courses,provider_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,courses,code,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,courses,title,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,courses,status,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,source_system,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,external_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,version_no,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,data_classification,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,correlation_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,created_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,updated_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,deleted_at,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,course_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,geo_location_id,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,start_date,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,end_date,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,capacity,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Educação,education_opportunities,status,database/migrations/2026_09_24_009000_investmoz_education.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,project_documents,project_id,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,project_documents,document_id,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,project_documents,created_at,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,investor_documents,investor_id,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,investor_documents,document_id,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,investor_documents,created_at,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,journey_documents,journey_id,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,journey_documents,document_id,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,journey_documents,created_at,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,aftercare_documents,aftercare_plan_id,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,aftercare_documents,document_id,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,aftercare_documents,created_at,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,education_documents,education_opportunity_id,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,education_documents,document_id,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Conteúdos — ligações tipadas,education_documents,created_at,database/migrations/2026_09_24_009500_investmoz_document_links.php,A.26; A.31.2,RECON-01 +Governação e auditoria,audit_events,id,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,A.26; A.31.2,RECON-01 +Governação e auditoria,audit_events,source_system,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,A.26; A.31.2,RECON-01 +Governação e auditoria,audit_events,external_id,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,A.26; A.31.2,RECON-01 +Governação e auditoria,audit_events,data_classification,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,A.26; A.31.2,RECON-01 +Governação e auditoria,audit_events,correlation_id,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,A.26; A.31.2,RECON-01 +Governação e auditoria,audit_events,created_at,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,A.26; A.31.2,RECON-01 +Governação e auditoria,audit_events,actor_user_id,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,A.26; A.31.2,RECON-01 +Governação e auditoria,audit_events,entity_type,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,A.26; A.31.2,RECON-01 +Governação e auditoria,audit_events,entity_id,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,A.26; A.31.2,RECON-01 +Governação e auditoria,audit_events,action,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,A.26; A.31.2,RECON-01 +Governação e auditoria,audit_events,occurred_at,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,A.26; A.31.2,RECON-01 +Governação e auditoria,audit_events,before_data,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,A.26; A.31.2,RECON-01 +Governação e auditoria,audit_events,after_data,database/migrations/2026_09_24_010000_investmoz_governance_ai.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,source_system,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,external_id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,version_no,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,data_classification,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,correlation_id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,created_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,updated_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,aggregate_type,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,aggregate_id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,event_type,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,payload,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,status,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,available_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,outbox_events,published_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,inbox_events,id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,inbox_events,source_system,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,inbox_events,external_id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,inbox_events,version_no,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,inbox_events,data_classification,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,inbox_events,correlation_id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,inbox_events,created_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,inbox_events,updated_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,inbox_events,message_id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,inbox_events,event_type,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,inbox_events,payload,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,inbox_events,status,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,inbox_events,received_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,inbox_events,processed_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,source_system,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,external_id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,version_no,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,data_classification,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,correlation_id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,created_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,updated_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,deleted_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,integration_name,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,direction,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,status,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,started_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,ended_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,sync_runs,totals,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,source_system,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,external_id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,version_no,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,data_classification,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,correlation_id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,created_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,updated_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,deleted_at,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,sync_run_id,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,entity_type,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,external_id_ref,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,issue_type,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,severity,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,status,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 +Integrações e eventos,reconciliation_issues,details,database/migrations/2026_09_24_011000_investmoz_integrations.php,A.26; A.31.2,RECON-01 diff --git a/docs/investmoz/unification/README.md b/docs/investmoz/unification/README.md new file mode 100644 index 00000000..a5f50e5a --- /dev/null +++ b/docs/investmoz/unification/README.md @@ -0,0 +1,66 @@ +# Unificação do Portal do Investidor + +## Entrega local + +Implementados catálogo multidimensional, página de educação, fichas relacionadas, grelha/lista/mapa de pontos, comparação, favoritos, exportação, pesquisa predictiva, manifestações privadas em cinco etapas, necessidades configuráveis, anexos, protocolo e integração com o intake/CRM existente. A área pessoal inclui perfil, empresas declaradas, conversa privada, notificações internas e consulta de versões encriptadas. Backoffice acrescenta contexto de oportunidade e administração de referências. + +Extensões aplicadas à base local: **93 tabelas**, incluindo dezassete novas e **114 opções configuráveis**. As contagens dos dados de negócio anteriores mantiveram-se; apenas `migrations` e `sessions` mudaram durante a verificação. Não foram semeadas oportunidades, empresas ou coordenadas fictícias na base operacional. + +- [Dados, segurança, migração, reversão e Go/No-Go](data-and-operations.md) +- [Rastreabilidade, cobertura, manuais e formação](traceability-and-manual.md) +- [Contrato OpenAPI 3.0.3](openapi.json) +- [Validação e limites dos ensaios](validation.md) + +Pontos de entrada: `/oportunidades`, `/educacao`, `/carteira`, `/investidor/manifestacoes` e `/management/investment/references`. Em gestão de uma oportunidade, usar o acesso ao contexto para associar clusters, corredores, âncoras e necessidades. + +Esta entrega não representa a conclusão integral do prompt: a matriz identifica os módulos ainda parciais e os ensaios externos por executar. + +**Âmbito confirmado pelo proprietário:** a consolidação da outra máquina será feita futuramente; não faz parte da execução local actual. + +## Levantamento e decisão — 27/09/2026 + +Fonte remota observada: https://teste-info.tempo.co.mz/portal-do-investidor, HTTP 200, HTML público com Inertia `investor-portal/welcome`. Código e base remotos estão noutra máquina, conforme confirmação do proprietário. Não foram inspeccionados nem alterados. A aplicação Laravel/React local é a base de consolidação autorizada. + +| Domínio | Página remota observada | Aplicação local antes do upgrade | Decisão e regra | +| --- | --- | --- | --- | +| Entrada | `/portal-do-investidor`, menus e pesquisa | `/`, sete áreas e serviços | Consolidar: uma navegação; conservar URLs antigas com redireccionamento local. | +| Sectores | Mineração, Energia, Agronegócio, Turismo, Infraestruturas | Cadastro `sectors`, detalhe e pesquisa | Manter cadastro local; não substituir descrições por scraping. | +| Oportunidades | Dois projectos identificados como exemplos fictícios em USD | `opportunities`, publicação, detalhe, interesse | Manter; exemplos remotos não migram como factos. Preservar inventário e natureza demonstrativa. | +| Território | Atalho de províncias, sem mapa comprovado no HTML | 6 corredores, 8 clusters, 15 âncoras publicados em dossiers | Adaptar: ligações tipadas a oportunidades, pesquisa e navegação conjuntas; não duplicar dossiers como oportunidades. | +| Educação | Não observada na página inicial | Procura, capacidade, lacunas e propostas internas | Criar descoberta pública das oportunidades educativas publicadas; preservar dados internos. | +| Procedimentos | Cinco passos de orientação | Seis etapas e links de preparação | Consolidar no roteiro local, sem transformar orientação em exigência legal. | +| Legislação e documentos | Menus e âncoras | Catálogo, ficheiros privados e download autorizado | Manter; não inferir diplomas ou taxas a partir de links. | +| Parcerias | Âncora `#parcerias` | Propostas, moderação e contacto intermediado | Manter fluxo local. | +| Contactos | Morada e telefones públicos da Tempo; email protegido no HTML | Contactos configuráveis e formulário | Preservar referência no inventário; não substituir contactos de atendimento nem atribuir mandato institucional automaticamente. | +| Interesse | CTA nas fichas | API idempotente, formulário simples, CRM e jornadas | Adaptar com rascunho, etapas, necessidades e anexos privados; conservar API e pedidos existentes. | +| Área pessoal | Não comprovada na página consultada | Painel, balcão e autenticação | Consolidar entradas e relacionar pedidos, favoritos e manifestações. | +| Administração | Não acessível publicamente | Gestão, auditoria, educação, IA | Adaptar com relações, referências e versões; não conceder privilégios ao registo público. | +| URLs/duplicação | Prefixo `/portal-do-investidor` | Rotas sem prefixo | Descontinuar entradas duplicadas através de aliases; conservar registos e origem. | + +## Arquitectura da informação + +- Início: pesquisa, contagens do catálogo, destaques, quatro dimensões de investimento e serviços. +- Investir: catálogo → oportunidade; clusters → ficha; corredores → ficha; projectos âncora → ficha; educação → catálogo filtrado. +- Preparar: sectores, procedimentos, legislação, documentos, parcerias e simulador de cenários. +- Acompanhar: área do investidor, manifestações/rascunhos, favoritos, balcão e apoio. +- Gerir: catálogo e contexto, tabelas de referência, atendimento/CRM, conteúdo, auditoria e acessos. + +As tabelas públicas de oportunidades, dossiers, projectos, educação e CRM mantêm identidades e finalidades distintas. As novas relações complementam-nas; não copiam factos entre catálogos para simular consolidação. + +## Backlog e critérios de aceitação + +| Prioridade | Épico | Dependência | Aceitação | +| --- | --- | --- | --- | +| P0 | Inventário e consolidação | HTML público + código local | Divergências e origem documentadas; sem importação de exemplos como reais. | +| P0 | Modelo de contexto e referências | Esquema existente | FKs, pivots, filtros combináveis, gestão e rollback que preserva dados. | +| P0 | Manifestação progressiva | Autenticação + serviço de intake | Rascunho privado, versão, opções de BD, necessidades, consentimento, anexos e protocolo idempotente. | +| P1 | Descoberta unificada | Relações e publicação | Cluster/corredor/âncora/educação pesquisáveis e relacionados nas fichas. | +| P1 | Exploração | Catálogo | Lista/grelha, comparação, favoritos, exportação e mapa de dados geográficos autorizados. | +| P1 | Backoffice e rastreabilidade | Modelo + gates | Relações editáveis, opções desactiváveis sem perda histórica, decisões auditadas. | +| P1 | QA e migração | Implementação | Testes isolados, backup/restauro, migração aditiva, verificação móvel/desktop. | +| P2 | Homologação e operação remota | Acesso à outra máquina | Código/base reconciliados, ensaio de deploy/reversão e monitorização no destino. | +| P2 | Formação | Fluxos validados | Manual, exercícios e sessão com participantes reais; não declarar formação executada sem evidência. | + +## Limites de verificação + +A aprovação dos requisitos está registada. Aprovação não substitui acesso técnico à outra máquina, dados geográficos ausentes, conteúdos institucionais ou evidência de testes. Produção remota, certificação WCAG e formação real só podem ser declaradas concluídas com execução comprovada. diff --git a/docs/investmoz/unification/browser-validation.json b/docs/investmoz/unification/browser-validation.json new file mode 100644 index 00000000..b322bd91 --- /dev/null +++ b/docs/investmoz/unification/browser-validation.json @@ -0,0 +1,165 @@ +{ + "date": "2026-09-27", + "environment": "APP_ENV=qa; isolated QA database; synthetic fixtures", + "reports": [ + { + "name": "education-mobile", + "title": "Oportunidades na Educação", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "opportunity-mobile", + "title": "Laboratório sintético de formação", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "workspace-mobile", + "title": "Área do investidor", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "profile-companies-mobile", + "title": "Perfil de investimento e empresas", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "identity-mobile", + "title": "Preparar manifestação de interesse", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "investment-mobile", + "title": "Preparar manifestação de interesse", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "needs-mobile", + "title": "Preparar manifestação de interesse", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "attachments-mobile", + "title": "Preparar manifestação de interesse", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "review-mobile", + "title": "Preparar manifestação de interesse", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "receipt-mobile", + "title": "Manifestação IR-2026-01M3J34C62CKWV6WPG0H3B9SCE", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "conversation-revision-mobile", + "title": "Manifestação IR-2026-01M3J34C62CKWV6WPG0H3B9SCE", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "notifications-mobile", + "title": "Notificações e acompanhamento", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "workspace-desktop", + "title": "Área do investidor", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "catalogue-desktop", + "title": "Catálogo de investimento", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "references-desktop", + "title": "Opções dos formulários e filtros", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "context-desktop", + "title": "Contexto: Laboratório sintético de formação", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "management-receipt-desktop", + "title": "Manifestação IR-2026-01M3J34C62CKWV6WPG0H3B9SCE", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + }, + { + "name": "references-mobile", + "title": "Opções dos formulários e filtros", + "overflow": false, + "breadcrumb": true, + "supportVisible": true, + "unlabelled": 0 + } + ], + "errors": [], + "workflow": { + "csrfEnforced": true, + "draftSaved": true, + "stepPreserved": true, + "privateAttachment": true, + "receipt": true, + "idempotentReplay": true, + "managerContext": true, + "ownerIsolation": true, + "profileCompanyPrefill": true, + "conversation": true, + "revisions": true, + "notifications": true + } +} \ No newline at end of file diff --git a/docs/investmoz/unification/data-and-operations.md b/docs/investmoz/unification/data-and-operations.md new file mode 100644 index 00000000..366c7810 --- /dev/null +++ b/docs/investmoz/unification/data-and-operations.md @@ -0,0 +1,92 @@ +# Dados, migração e operação da unificação + +## Modelo e dicionário da extensão + +O esquema executável está em `database/migrations/2026_09_27_140000_investment_unification.php` e `2026_09_27_150000_investor_workspace.php`. Todas as relações têm chaves externas com eliminação restringida. Identificadores territoriais são UUID; oportunidades e utilizadores conservam os seus identificadores numéricos. + +| Tabela | Chave / relação | Conteúdo e regra | +| --- | --- | --- | +| `investment_reference_options` | `id`; único `(group,code)` | Rótulo, actividade, ordem, obrigatoriedade de especificação. Códigos estáveis; desactivar não elimina referências históricas. | +| `opportunity_profiles` | PK/FK `opportunity_id` | Educação, modelo de negócio, requisitos, riscos, data-alvo, fonte, versão. Texto destinado à ficha pública após publicação. Não colocar aqui notas privadas. | +| `opportunity_clusters` | PK `(opportunity_id,cluster_id)` | Uma oportunidade pode pertencer a vários clusters. | +| `opportunity_corridors` | PK `(opportunity_id,corridor_id)` | Vários corredores por oportunidade. | +| `opportunity_projects` | PK `(opportunity_id,project_id)` | Vários projectos âncora por oportunidade. | +| `opportunity_locations` | PK `(opportunity_id,geo_location_id)` | Ligações às localizações existentes. Geometrias continuam em `geo_geometries`, PostGIS SRID 4326. | +| `opportunity_facets` | PK `(opportunity_id,option_id)` | Subsector, parceria, impacto, educação, infraestrutura, competências e necessidades configuráveis. | +| `investment_submissions` | UUID; dono `user_id`; recibo único `interest_request_id` | `payload` encriptado, versão, estado draft/submitted. No máximo um destino: oportunidade ou dossier; interesse geral admite nenhum. Submissão exige recibo e rascunho não o admite. | +| `investment_specific_needs` | `submission_id`; opções FK | Categoria, tipo, quantidade decimal não negativa, unidade, prioridade, prazo. Descrição, localização e demais detalhes são encriptados. | +| `investment_submission_attachments` | UUID; submissão/documento FK | Ficheiro privado em `documents`; nome, tipo, tamanho e checksum canónicos. | +| `investment_submission_events` | `submission_id`, actor, versão | Criação, gravação, anexo e submissão. Guarda actos e versões; não é um arquivo de cópias integrais dos rascunhos. | +| `opportunity_favourites` | PK `(user_id,opportunity_id)` | Favoritos privados, sem duplicações. Retirada de publicação oculta-os. | +| `investment_accounts` | PK/FK `user_id` | Perfil de investimento encriptado e versionado; complementa nome/email da conta. | +| `investment_company_declarations` | UUID; dono `user_id` | Dados empresariais declarados, encriptados e versionados; até vinte por utilizador. Não altera `organizations` ou `memberships`, nem comprova representação. | +| `investment_messages` | UUID; submissão/actor; chave única `(submission_id,actor_id,client_key)` | Conversa encriptada depois da submissão. Repetição com mesmo texto devolve ID; reutilização da chave com outro texto é conflito. | +| `investment_notifications` | UUID; utilizador/submissão; evento único por destinatário | Confirmação, estado e mensagem, conteúdo encriptado, data de leitura. Notificações internas, sem envio de email nesta extensão. | +| `investment_submission_revisions` | único `(submission_id,version)` | Snapshot encriptado do formulário, necessidades e IDs dos anexos em cada nova versão. Consulta por titular/gestor, sem restauro automático. | + +Relações principais: `users → investment_submissions → investment_specific_needs`; `investment_submissions → investment_submission_attachments → documents`; `investment_submissions → interest_requests → outbox_events → CRM`; `opportunities ↔ clusters/corridors/projects/locations` através dos quatro pivots. Dossiers continuam ligados às entidades territoriais originais, sem serem copiados para `opportunities`. + +O `payload` admite nome, email, telefone, país, organização, função, experiência, capacidade declarada, perfil, sector, objectivo, participação, parceria, montante/moeda, horizonte, localização, referências territoriais, especificação de Outro e consentimento. Campos desconhecidos são recusados. As necessidades admitem descrição, localização, observações, qualificação, especialidade, experiência, certificações, disponibilidade, instituição, modalidade/duração de formação e estado declarado. Os dados quantitativos não são indicadores territoriais verificados. + +## Regras de visibilidade e segurança + +- A pesquisa exige oportunidade publicada, data de publicação atingida e ausência de eliminação lógica. Relações territoriais públicas exigem dossier publicado; localizações e geometrias exigem classificação pública. +- O mapa devolve apenas pontos publicados; não estima coordenadas nem consulta serviços externos. A lista textual contém os mesmos locais. +- Editar contexto devolve a oportunidade a rascunho. A versão recebida tem de coincidir sob bloqueio transaccional; conflito devolve 409. +- Só o titular altera a sua manifestação. Gestores autorizados podem lê-la para atendimento. Outro investidor recebe 404. Após submissão, alterações são recusadas e repetir o envio devolve o mesmo protocolo. +- A submissão, o recibo e o evento de integração são gravados atomicamente pelo serviço existente `InterestSubmission`. Nenhum envio externo SuiteCRM é activado por esta extensão. +- Anexos PDF/TXT: até 10 MiB cada, dez por manifestação. Armazenamento privado; acesso revalida dono/gestor, estado, directório e SHA-256. Os anexos não integram o catálogo público. +- `payload` e detalhes usam encriptação Laravel. Preservar `APP_KEY` com o backup e restringir o acesso; trocar a chave sem plano torna estes campos ilegíveis. +- Auditoria guarda actor, identificador, acto e versão, sem copiar perguntas, contactos ou texto privado. A retenção e eliminação de dados pessoais ainda exigem uma política operacional implementada. +- As revisões integrais privadas ficam separadas da auditoria. Quem deixa de ser gestor perde acesso a pedidos e notificações de terceiros. Mensagens só são permitidas em manifestações submetidas e não alteram a versão do formulário. +- APIs privadas usam sessão web, email verificado, CSRF e limites de pedidos. Registo público não atribui gestão. Papéis técnico/validador/promotor separados ainda não foram introduzidos nesta extensão. + +## Migração local executada em 27/09/2026 + +1. Backup privado `storage/app/private/unification-backup-20260927-134405/database.dump` e manifesto SHA-256. +2. Restauro numa base isolada `unification_restore_20260927134405_test`, PostgreSQL de QA na porta 55441; comparação de contagens das 76 tabelas existentes antes de migrar. +3. Aplicação exclusiva da migration `2026_09_27_140000_investment_unification` e de `InvestmentReferenceSeeder`. São doze tabelas adicionais. O seed usa `insertOrIgnore`: não substitui opções editadas. +4. Não se executou `DatabaseSeeder`, não se importaram exemplos remotos e não se criaram oportunidades, empresas ou coordenadas fictícias na base local. + +Segunda extensão: migration `2026_09_27_150000_investor_workspace`, após backup/restauro verificado de 88 tabelas em `storage/app/private/workspace-backup-20260927-184429/`. Resultado: **93 tabelas**; as cinco novas ficaram vazias. Reconciliação após a segunda migration: apenas `migrations` mudou (33 → 34); contagens das restantes 87 tabelas preservadas. O seed de instalação local passou também a incluir as opções de investimento; não foi reexecutado globalmente na base operacional. + +O backup integral anterior, `C:\laragon\backups\portal-investidor-20260927-101224`, tem instruções em `RESTAURO.md`. Representa uma versão anterior à extensão das sete áreas públicas; não misturar silenciosamente esse código com a base nova. + +## Aplicação noutro ambiente + +Reconciliar primeiro código, migrations aplicadas e conteúdo da outra máquina. Gerar backup integral de código, base, ficheiros privados e chave, e ensaiar restauro isolado. Preservar IDs/origens remotos numa tabela de correspondência antes de qualquer importação; comparar contagens, unicidade, FKs, checksums e conteúdo por categoria. Exemplos demonstrativos ficam excluídos do catálogo real. A comparação pública não prova equivalência das bases. + +Com o código compatível e o backup conferido: + +```sh +php artisan migrate --path=database/migrations/2026_09_27_140000_investment_unification.php --force +php artisan db:seed --class=InvestmentReferenceSeeder --force +php artisan migrate --path=database/migrations/2026_09_27_150000_investor_workspace.php --force +npm run build +php artisan optimize:clear +``` + +Executar os testes apenas com `APP_ENV=testing` e base isolada terminada em `_test`. Nunca usar `migrate:fresh` na base operacional. + +## Reversão e monitorização + +O `down()` recusa remover tabelas com dados, incluindo as opções semeadas. Para reverter código, conservar as tabelas aditivas e retirar os novos acessos. Para restaurar a base integral, suspender escritas e trabalhadores, guardar primeiro os dados recebidos depois do backup, restaurar numa base nova e reconciliar antes de mudar a ligação. Não substituir a base activa durante a verificação. O ensaio desta entrega prova restauro do dump anterior; não prova reversão de produção com tráfego concorrente. + +Monitorizar erros HTTP 5xx, conflitos 409, recusas 403/404, limites 429, fila/outbox pendente e falhas de entrega, integridade dos anexos, espaço em disco, backups e latência de pesquisa/submissão. Não colocar nomes, emails, conteúdo de anexos ou formulários em métricas. Medir p50/p95/p99 no destino antes de definir SLA; medições locais de poucos registos não são ensaio de carga. + +## Go / No-Go + +| Item | Situação | +| --- | --- | +| Autorização dos requisitos | Expressamente concedida pelo proprietário; não requer nova aprovação genérica. | +| Extensão local e opções | Aplicadas após backup/restauro. | +| QA automatizado e navegador | Evidências em `validation.md`; conferir limitações de cada ensaio. | +| Conteúdo e geografia | Publica-se apenas o existente autorizado; completar dados com fontes reais. | +| Consolidação da outra máquina | Adiada pelo proprietário para fase futura; fora do âmbito da execução local actual. | +| WCAG 2.2 AA | Verificações parciais; falta auditoria completa com tecnologia assistiva. | +| Área pessoal, empresas declaradas, mensagens e notificações | Implementadas e testadas localmente; sem atribuição automática de representação empresarial. | +| Perfis especializados e cadastro territorial completo | Cobertura parcial; ver matriz de rastreabilidade. | +| SuiteCRM real, carga, recuperação no destino | Não ensaiados nesta entrega. | +| Formação e produção | Plano preparado; sessões e entrada em produção não executadas. | + +Resultado: disponível para utilização e avaliação local. A conclusão integral do prompt e a prontidão de produção não estão demonstradas pelos testes desta extensão. diff --git a/docs/investmoz/unification/local-latency.json b/docs/investmoz/unification/local-latency.json new file mode 100644 index 00000000..4592efad --- /dev/null +++ b/docs/investmoz/unification/local-latency.json @@ -0,0 +1,48 @@ +{ + "date": "2026-09-27", + "environment": "local PHP development server, sequential loopback HTTP requests; no load or production SLA claim", + "measurements": [ + { + "path": "/api/v1/investment/search", + "repetitions": 5, + "median_ms": 170.67, + "min_ms": 154.9, + "max_ms": 207.66, + "samples_ms": [ + 207.66, + 154.9, + 168.66, + 198.91, + 170.67 + ] + }, + { + "path": "/api/v1/investment/references", + "repetitions": 5, + "median_ms": 173.39, + "min_ms": 151.81, + "max_ms": 210.99, + "samples_ms": [ + 210.99, + 184.29, + 162.7, + 173.39, + 151.81 + ] + }, + { + "path": "/api/v1/investment/map", + "repetitions": 5, + "median_ms": 160.86, + "min_ms": 148.68, + "max_ms": 198.9, + "samples_ms": [ + 172.47, + 160.11, + 198.9, + 160.86, + 148.68 + ] + } + ] +} \ No newline at end of file diff --git a/docs/investmoz/unification/openapi.json b/docs/investmoz/unification/openapi.json new file mode 100644 index 00000000..9be5f221 --- /dev/null +++ b/docs/investmoz/unification/openapi.json @@ -0,0 +1,4877 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "InvestMoz — descoberta e manifestações", + "version": "1.0.0", + "description": "Contrato local 27/09/2026. Sessão web Laravel, email verificado e CSRF; não usa bearer independente. Accept: application/json. Respostas privadas no-store. Estados de atendimento: received,in_review,contacted,closed. API pública usa throttle api da aplicação; limites específicos indicados por operação. O middleware Laravel 13 aceita origem same-origin comprovada ou token CSRF válido; clientes devem enviar o token. Pedidos cross-site sem token são recusados. Pesquisa, referências, sugestão, mapa, comparação, exportação e favoritos partilham 60 pedidos/minuto por IP." + }, + "servers": [ + { + "url": "/api/v1/investment" + } + ], + "security": [], + "paths": { + "/search": { + "get": { + "summary": "Pesquisar oportunidades publicadas com filtros em intersecção.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page" + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string", + "maxLength": 120 + } + }, + { + "name": "sector", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "province", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "district", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cluster_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "corridor_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "project_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "geo_location_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "education", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "maturity_level", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 6 + } + }, + { + "name": "facets", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "integer" + }, + "maxItems": 20 + } + }, + { + "name": "min_amount", + "in": "query", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "max_amount", + "in": "query", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "currency", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "MZN", + "USD", + "EUR", + "ZAR" + ] + } + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "recent", + "title", + "amount_asc", + "amount_desc" + ] + } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 12 + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "zee_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Current validated public association. AND with other filters." + }, + { + "name": "zfi_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Current validated public association. AND with other filters." + }, + { + "name": "park_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Current validated public association. AND with other filters." + }, + { + "name": "dimension", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "cluster", + "corridor", + "zee", + "zfi" + ] + }, + "description": "Require a visible association in the selected dimension, preserving other filters." + }, + { + "name": "view", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "grid", + "list", + "map", + "combined" + ] + }, + "description": "Presentation state retained in URL; does not alter permission checks." + } + ], + "description": "Min/max exigem currency; max >= min. Sem câmbio. Facetas: facets[]=1&facets[]=2. Ordenação monetária agrupada por moeda." + } + }, + "/references": { + "get": { + "summary": "Opções activas e territórios públicos.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/References" + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/opportunities/{slug}": { + "get": { + "summary": "Ficha publicada e contexto.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Detail" + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/suggest": { + "get": { + "summary": "Sugestões a partir de dois caracteres; até cinco de cada tipo.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "opportunities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "slug": { + "type": "string" + } + } + } + }, + "territorial": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string", + "maxLength": 120 + } + }, + { + "name": "sector", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "province", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "district", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cluster_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "corridor_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "project_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "geo_location_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "education", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "maturity_level", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 6 + } + }, + { + "name": "facets", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "integer" + }, + "maxItems": 20 + } + }, + { + "name": "min_amount", + "in": "query", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "max_amount", + "in": "query", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "currency", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "MZN", + "USD", + "EUR", + "ZAR" + ] + } + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "recent", + "title", + "amount_asc", + "amount_desc" + ] + } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 12 + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "zee_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Current validated public association. AND with other filters." + }, + { + "name": "zfi_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Current validated public association. AND with other filters." + }, + { + "name": "park_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Current validated public association. AND with other filters." + }, + { + "name": "dimension", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "cluster", + "corridor", + "zee", + "zfi" + ] + }, + "description": "Require a visible association in the selected dimension, preserving other filters." + }, + { + "name": "view", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "grid", + "list", + "map", + "combined" + ] + }, + "description": "Presentation state retained in URL; does not alter permission checks." + } + ] + } + }, + "/map": { + "get": { + "summary": "Até mil pontos públicos WGS84, com filtros.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Map" + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string", + "maxLength": 120 + } + }, + { + "name": "sector", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "province", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "district", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cluster_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "corridor_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "project_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "geo_location_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "education", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "maturity_level", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 6 + } + }, + { + "name": "facets", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "integer" + }, + "maxItems": 20 + } + }, + { + "name": "min_amount", + "in": "query", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "max_amount", + "in": "query", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "currency", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "MZN", + "USD", + "EUR", + "ZAR" + ] + } + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "recent", + "title", + "amount_asc", + "amount_desc" + ] + } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 12 + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "zee_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Current validated public association. AND with other filters." + }, + { + "name": "zfi_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Current validated public association. AND with other filters." + }, + { + "name": "park_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Current validated public association. AND with other filters." + }, + { + "name": "dimension", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "cluster", + "corridor", + "zee", + "zfi" + ] + }, + "description": "Require a visible association in the selected dimension, preserving other filters." + }, + { + "name": "view", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "grid", + "list", + "map", + "combined" + ] + }, + "description": "Presentation state retained in URL; does not alter permission checks." + } + ] + } + }, + "/compare": { + "get": { + "summary": "Comparar até quatro oportunidades publicadas.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Detail" + } + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "ids", + "in": "query", + "required": true, + "description": "Enviar ids[]=1&ids[]=2", + "schema": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 4, + "uniqueItems": true + } + } + ] + } + }, + "/export": { + "get": { + "summary": "CSV UTF-8 com BOM; até mil resultados; fórmulas neutralizadas.", + "responses": { + "200": { + "description": "CSV separado por ponto e vírgula", + "content": { + "text/csv": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string", + "maxLength": 120 + } + }, + { + "name": "sector", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "province", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "district", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cluster_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "corridor_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "project_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "geo_location_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "education", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "maturity_level", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 6 + } + }, + { + "name": "facets", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "integer" + }, + "maxItems": 20 + } + }, + { + "name": "min_amount", + "in": "query", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "max_amount", + "in": "query", + "schema": { + "type": "number", + "minimum": 0 + } + }, + { + "name": "currency", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "MZN", + "USD", + "EUR", + "ZAR" + ] + } + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "recent", + "title", + "amount_asc", + "amount_desc" + ] + } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 12 + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "zee_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Current validated public association. AND with other filters." + }, + { + "name": "zfi_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Current validated public association. AND with other filters." + }, + { + "name": "park_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Current validated public association. AND with other filters." + }, + { + "name": "dimension", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "cluster", + "corridor", + "zee", + "zfi" + ] + }, + "description": "Require a visible association in the selected dimension, preserving other filters." + }, + { + "name": "view", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "grid", + "list", + "map", + "combined" + ] + }, + "description": "Presentation state retained in URL; does not alter permission checks." + } + ] + } + }, + "/favourites/{id}": { + "post": { + "summary": "Guardar/retirar favorito próprio.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "saved": { + "type": "boolean" + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "session": [], + "csrf": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "saved": { + "type": "boolean" + } + }, + "required": [ + "saved" + ] + } + } + } + } + } + }, + "/submissions": { + "get": { + "summary": "Manifestações próprias (20/página) e favoritos publicados.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "submissions": { + "type": "object", + "properties": {}, + "description": "Paginator Laravel: data, total, current_page, next_page_url, prev_page_url; linhas com id,status,version,created_at,reference,request_status." + }, + "favourites": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Opportunity" + } + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "session": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "summary": "Criar rascunho próprio; no máximo um destino publicado; 10/min.", + "responses": { + "201": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "version": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "session": [], + "csrf": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "opportunity_id": { + "type": "integer", + "nullable": true + }, + "dossier_id": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "company_id": { + "type": "string", + "format": "uuid", + "nullable": true, + "description": "Declaração de empresa do próprio utilizador, usada para pré-preenchimento." + }, + "zone_id": { + "type": "string", + "format": "uuid", + "nullable": true, + "description": "Published zone origin; may combine with a publicly associated opportunity, not with dossier_id or company_id." + }, + "intent": { + "type": "string", + "enum": [ + "interest", + "dossier", + "meeting" + ], + "description": "Required with zone_id. Reuses the existing draft for this user, zone version, opportunity and intent." + } + } + } + } + } + } + } + }, + "/submissions/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "get": { + "summary": "Consultar pedido próprio ou, com manage-portal, pedido em atendimento.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "submission": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "version": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/Payload" + } + } + }, + "needs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Need" + } + }, + "references": { + "$ref": "#/components/schemas/References" + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "file_name": { + "type": "string" + } + } + } + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "version": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "receipt": { + "type": "object", + "properties": { + "reference": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "nullable": true + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "sender_role": { + "type": "string", + "enum": [ + "investor", + "support" + ] + }, + "body": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "revisions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "version": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "session": [] + } + ] + }, + "put": { + "summary": "Guardar rascunho próprio com versão optimista; 30/min.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "session": [], + "csrf": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Draft" + } + } + } + } + } + }, + "/submissions/{id}/submit": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "post": { + "summary": "Submeter atomicamente; reenvio devolve protocolo e estado existentes; 10/min.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reference": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "session": [], + "csrf": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Draft" + } + } + } + }, + "description": "Somente titular. Exige campos finais e consentimento. Um recibo e uma outbox de CRM; sem activação de conector externo." + } + }, + "/submissions/{id}/attachments": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "post": { + "summary": "Carregar anexo privado em rascunho; 10/min.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "session": [], + "csrf": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "integer" + }, + "file": { + "type": "string", + "format": "binary", + "description": "PDF/TXT até 10 MiB; máximo dez anexos." + } + }, + "required": [ + "version", + "file" + ] + } + } + } + } + } + }, + "/submissions/{id}/attachments/{attachment}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "attachment", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "get": { + "summary": "Download privado após validar titular/gestor, caminho, estado e checksum.", + "responses": { + "200": { + "description": "Attachment; no-store, nosniff", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "session": [] + } + ] + } + }, + "/admin/opportunities/{id}/context": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "put": { + "summary": "Gestor altera contexto; devolve oportunidade a rascunho.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "session": [], + "csrf": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContextInput" + } + } + } + }, + "description": "manage-portal obrigatório. version=0 antes da criação do perfil. Opções activas, entidades não eliminadas. Não publica." + } + }, + "/admin/references": { + "post": { + "summary": "Gestor cria/actualiza opção; grupo e código estáveis.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "session": [], + "csrf": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Option" + } + } + } + }, + "description": "manage-portal obrigatório. Grupo existente; Outro mantém actividade e requires_description. Sem eliminação histórica." + } + }, + "/admin/references/{id}": { + "post": { + "summary": "Gestor cria/actualiza opção; grupo e código estáveis.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "session": [], + "csrf": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Option" + } + } + } + }, + "description": "manage-portal obrigatório. Grupo existente; Outro mantém actividade e requires_description. Sem eliminação histórica." + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ] + }, + "/profile": { + "get": { + "summary": "Perfil privado e empresas declaradas do titular.", + "security": [ + { + "session": [] + } + ], + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "account": { + "$ref": "#/components/schemas/InvestorAccount" + }, + "companies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanyDeclaration" + } + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "summary": "Guardar perfil privado com versão; 20/min.", + "security": [ + { + "session": [], + "csrf": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvestorAccount" + } + } + } + }, + "description": "version=0 inicialmente. Campos desconhecidos recusados; não altera nome/email nem concede representação de empresa.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/companies": { + "post": { + "summary": "Guardar declaração privada de empresa; 20/min.", + "security": [ + { + "session": [], + "csrf": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanyDeclaration" + } + } + } + }, + "description": "Titular apenas. Máximo vinte declarações; versão zero para criar. Não cria memberships nem altera organizações canónicas.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "version": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/companies/{id}": { + "post": { + "summary": "Guardar declaração privada de empresa; 20/min.", + "security": [ + { + "session": [], + "csrf": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanyDeclaration" + } + } + } + }, + "description": "Titular apenas. Máximo vinte declarações; versão zero para criar. Não cria memberships nem altera organizações canónicas.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "version": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ] + }, + "/notifications": { + "get": { + "summary": "Notificações próprias, vinte por página, e contagem não lida.", + "security": [ + { + "session": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "unread": { + "type": "integer" + }, + "notifications": { + "type": "object", + "properties": {}, + "description": "Paginator Laravel com data: id,submission_id,kind,payload,read_at,created_at. Tipos received,status,message. Sem email externo." + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/notifications/{id}/read": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "post": { + "summary": "Marcar notificação própria como lida; idempotente.", + "security": [ + { + "session": [], + "csrf": [] + } + ], + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "read": { + "type": "boolean" + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/submissions/{id}/messages": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "post": { + "summary": "Mensagem privada em pedido submetido; 10/min.", + "security": [ + { + "session": [], + "csrf": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "client_key": { + "type": "string", + "format": "uuid" + }, + "body": { + "type": "string", + "minLength": 3, + "maxLength": 5000 + } + }, + "required": [ + "client_key", + "body" + ] + } + } + } + }, + "description": "Titular ou gestor. Mesma chave e texto devolvem a mensagem existente. Chave reutilizada com texto diferente:409. Texto encriptado; auditoria sem corpo. Cria notificações internas para a outra parte.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/submissions/{id}/revisions/{version}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "get": { + "summary": "Consultar versão histórica encriptada do pedido.", + "security": [ + { + "session": [] + } + ], + "description": "Titular ou gestor actual. Só versões gravadas após esta extensão. Não altera nem restaura automaticamente o pedido; no-store.", + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "integer" + }, + "snapshot": { + "type": "object", + "properties": { + "payload": { + "$ref": "#/components/schemas/Payload" + }, + "status": { + "type": "string" + }, + "needs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Need" + } + }, + "attachment_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/admin/interests/{interest}/status": { + "parameters": [ + { + "name": "interest", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "patch": { + "summary": "Gestor actualiza estado de atendimento e notifica o titular quando muda.", + "security": [ + { + "session": [], + "csrf": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "received", + "in_review", + "contacted", + "closed" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Sucesso", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reference": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "Sem sessão", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Email não verificado / sem manage-portal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Registo ausente ou invisível", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Versão desactualizada / pedido submetido", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "419": { + "description": "Sessão / CSRF expirado", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Validação", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Limite de pedidos", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Exige manage-portal. Alteração transaccional com auditoria. Repetir o mesmo estado não cria outra notificação." + } + } + }, + "components": { + "securitySchemes": { + "session": { + "type": "apiKey", + "in": "cookie", + "name": "laravel_session", + "description": "Nome configurável SESSION_COOKIE; autenticar pelo fluxo web." + }, + "csrf": { + "type": "apiKey", + "in": "header", + "name": "X-XSRF-TOKEN", + "description": "Valor descodificado do cookie XSRF-TOKEN." + } + }, + "schemas": { + "Error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "Version": { + "type": "object", + "properties": { + "version": { + "type": "integer" + } + } + }, + "Entity": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + } + }, + "Option": { + "type": "object", + "required": [ + "group", + "code", + "label", + "active", + "requires_description", + "sort_order" + ], + "properties": { + "id": { + "type": "integer" + }, + "group": { + "type": "string" + }, + "code": { + "type": "string", + "maxLength": 80, + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "label": { + "type": "string", + "maxLength": 200 + }, + "active": { + "type": "boolean" + }, + "requires_description": { + "type": "boolean" + }, + "sort_order": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + } + } + }, + "Payload": { + "type": "object", + "additionalProperties": false, + "properties": { + "full_name": { + "type": "string", + "nullable": true, + "maxLength": 150 + }, + "email": { + "type": "string", + "nullable": true, + "maxLength": 150 + }, + "phone": { + "type": "string", + "nullable": true + }, + "country": { + "type": "string", + "nullable": true + }, + "organisation": { + "type": "string", + "nullable": true + }, + "representative_role": { + "type": "string", + "nullable": true + }, + "experience": { + "type": "string", + "nullable": true + }, + "investment_capacity": { + "type": "string", + "nullable": true + }, + "sector": { + "type": "string", + "nullable": true + }, + "objective": { + "type": "string", + "maxLength": 5000, + "nullable": true + }, + "currency": { + "type": "string", + "nullable": true + }, + "horizon": { + "type": "string", + "nullable": true + }, + "location": { + "type": "string", + "nullable": true + }, + "other_details": { + "type": "string", + "nullable": true + }, + "contact_consent": { + "type": "boolean" + }, + "estimated_amount": { + "type": "number", + "minimum": 0, + "nullable": true + }, + "profile_id": { + "type": "integer", + "nullable": true + }, + "participation_id": { + "type": "integer", + "nullable": true + }, + "partnership_id": { + "type": "integer", + "nullable": true + }, + "cluster_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 20, + "uniqueItems": true + }, + "corridor_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 20, + "uniqueItems": true + }, + "project_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 30, + "uniqueItems": true + } + }, + "description": "Para submeter: full_name, email, country, profile_id, sector, objective, participation_id e horizon obrigatórios; contact_consent=true. Moeda obrigatória com montante. Outro exige other_details. Opções activas do grupo correcto. Drafts permit incomplete name, email and objective. Submission requires a name of at least 3 characters, RFC email and objective of at least 20 characters." + }, + "Draft": { + "type": "object", + "required": [ + "version", + "payload" + ], + "properties": { + "version": { + "type": "integer", + "minimum": 1 + }, + "payload": { + "$ref": "#/components/schemas/Payload" + }, + "needs": { + "type": "array", + "maxItems": 40, + "items": { + "$ref": "#/components/schemas/Need" + } + } + } + }, + "Need": { + "type": "object", + "additionalProperties": false, + "required": [ + "category_id", + "option_id", + "details" + ], + "properties": { + "category_id": { + "type": "integer" + }, + "option_id": { + "type": "integer" + }, + "unit_id": { + "type": "integer", + "nullable": true + }, + "priority_id": { + "type": "integer", + "nullable": true + }, + "quantity": { + "type": "number", + "nullable": true, + "minimum": 0, + "maximum": 999999999999 + }, + "deadline": { + "type": "string", + "format": "date", + "nullable": true + }, + "details": { + "type": "object", + "additionalProperties": false, + "required": [ + "description" + ], + "properties": { + "description": { + "type": "string", + "nullable": false, + "maxLength": 2000, + "minLength": 3 + }, + "location": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "observations": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "qualification": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "specialty": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "experience": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "certifications": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "availability": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "institution": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "training_mode": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "duration": { + "type": "string", + "nullable": true, + "maxLength": 2000 + }, + "status": { + "type": "string", + "nullable": true, + "maxLength": 2000 + } + } + } + } + }, + "Opportunity": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "summary": { + "type": "string", + "nullable": true + }, + "province": { + "type": "string", + "nullable": true + }, + "district": { + "type": "string", + "nullable": true + }, + "estimated_investment_amount": { + "type": "string", + "nullable": true, + "example": "1500000.00" + }, + "estimated_investment_currency": { + "type": "string" + }, + "maturity_label": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "sector": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + } + } + } + }, + "Page": { + "type": "object", + "properties": { + "current_page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "last_page": { + "type": "integer" + }, + "next_page_url": { + "type": "string", + "nullable": true + }, + "prev_page_url": { + "type": "string", + "nullable": true + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Opportunity" + } + } + } + }, + "ContextInput": { + "type": "object", + "required": [ + "version", + "is_education", + "source_reference" + ], + "properties": { + "version": { + "type": "integer", + "minimum": 0 + }, + "is_education": { + "type": "boolean" + }, + "source_reference": { + "type": "string", + "minLength": 10, + "maxLength": 2000 + }, + "business_model": { + "type": "string", + "maxLength": 5000, + "nullable": true + }, + "requirements": { + "type": "string", + "maxLength": 10000, + "nullable": true + }, + "risks": { + "type": "string", + "maxLength": 5000, + "nullable": true + }, + "target_date": { + "type": "string", + "format": "date", + "nullable": true + }, + "facets": { + "type": "array", + "maxItems": 100, + "items": { + "type": "integer" + }, + "uniqueItems": true + }, + "clusters": { + "type": "array", + "maxItems": 100, + "items": { + "type": "string", + "format": "uuid" + }, + "uniqueItems": true + }, + "corridors": { + "type": "array", + "maxItems": 100, + "items": { + "type": "string", + "format": "uuid" + }, + "uniqueItems": true + }, + "projects": { + "type": "array", + "maxItems": 100, + "items": { + "type": "string", + "format": "uuid" + }, + "uniqueItems": true + }, + "locations": { + "type": "array", + "maxItems": 100, + "items": { + "type": "string", + "format": "uuid" + }, + "uniqueItems": true + } + } + }, + "Context": { + "type": "object", + "properties": { + "profile": { + "type": "object", + "nullable": true, + "properties": { + "version": { + "type": "integer", + "minimum": 0 + }, + "is_education": { + "type": "boolean" + }, + "source_reference": { + "type": "string", + "minLength": 10, + "maxLength": 2000 + }, + "business_model": { + "type": "string", + "maxLength": 5000, + "nullable": true + }, + "requirements": { + "type": "string", + "maxLength": 10000, + "nullable": true + }, + "risks": { + "type": "string", + "maxLength": 5000, + "nullable": true + }, + "target_date": { + "type": "string", + "format": "date", + "nullable": true + }, + "facets": { + "type": "array", + "maxItems": 100, + "items": { + "type": "integer" + }, + "uniqueItems": true + }, + "clusters": { + "type": "array", + "maxItems": 100, + "items": { + "type": "string", + "format": "uuid" + }, + "uniqueItems": true + }, + "corridors": { + "type": "array", + "maxItems": 100, + "items": { + "type": "string", + "format": "uuid" + }, + "uniqueItems": true + }, + "projects": { + "type": "array", + "maxItems": 100, + "items": { + "type": "string", + "format": "uuid" + }, + "uniqueItems": true + }, + "locations": { + "type": "array", + "maxItems": 100, + "items": { + "type": "string", + "format": "uuid" + }, + "uniqueItems": true + } + } + }, + "facets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "group": { + "type": "string" + }, + "label": { + "type": "string" + } + } + } + }, + "clusters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Entity" + } + }, + "corridors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Entity" + } + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Entity" + } + }, + "locations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Entity" + } + } + } + }, + "References": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Option" + } + }, + "sectors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + } + } + }, + "clusters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Entity" + } + }, + "corridors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Entity" + } + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Entity" + } + }, + "locations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Entity" + } + } + } + }, + "Detail": { + "type": "object", + "properties": { + "record": { + "$ref": "#/components/schemas/Opportunity" + }, + "context": { + "$ref": "#/components/schemas/Context" + } + } + }, + "Map": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "FeatureCollection" + ] + }, + "limit": { + "type": "integer", + "enum": [ + 1000 + ] + }, + "features": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "Feature" + ] + }, + "geometry": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "Point" + ] + }, + "coordinates": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "number" + } + } + } + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "location": { + "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + } + } + }, + "InvestorAccount": { + "type": "object", + "properties": { + "version": { + "type": "integer" + }, + "payload": { + "type": "object", + "properties": { + "phone": { + "type": "string", + "nullable": true + }, + "country": { + "type": "string", + "nullable": true + }, + "sector": { + "type": "string", + "nullable": true + }, + "experience": { + "type": "string", + "nullable": true + }, + "investment_capacity": { + "type": "string", + "nullable": true + } + } + } + }, + "required": [ + "version", + "payload" + ] + }, + "CompanyDeclaration": { + "type": "object", + "properties": { + "version": { + "type": "integer" + }, + "payload": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "country": { + "type": "string" + }, + "registration": { + "type": "string" + }, + "sector": { + "type": "string" + }, + "role": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "name", + "country", + "role" + ], + "additionalProperties": false + } + }, + "required": [ + "version", + "payload" + ] + } + } + } +} diff --git a/docs/investmoz/unification/public-browser-validation.json b/docs/investmoz/unification/public-browser-validation.json new file mode 100644 index 00000000..a9a233a9 --- /dev/null +++ b/docs/investmoz/unification/public-browser-validation.json @@ -0,0 +1,87 @@ +{ + "reports": [ + { + "path": "/", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Oportunidades reais de investimento em Moçambique, organizadas por sector." + }, + { + "path": "/oportunidades", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Catálogo de investimento" + }, + { + "path": "/sectores", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Sectores Prioritários" + }, + { + "path": "/como-investir", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Como Investir / Procedimentos" + }, + { + "path": "/legislacao", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Legislação e Incentivos" + }, + { + "path": "/documentos", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Documentos e Formulários" + }, + { + "path": "/apoio", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Apoio ao Investidor" + }, + { + "path": "/parcerias", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Bolsa de Parcerias" + }, + { + "path": "/simulador-fiscal", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Simulador Fiscal" + }, + { + "path": "/formularios/empresa", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Formulário de empresa" + }, + { + "path": "/login", + "breadcrumb": true, + "supportVisible": true, + "overflow": false, + "title": "Log in to your account" + } + ], + "simulation": "Sem redução: 100,00 MTnRedução simulada: 50,00 MTnTotal do cenário: 50,00 MTnBase × taxa ÷ 100 × (1 − redução ÷ 100). Não inclui outros impostos, deduções, escalões ou regras sectoriais.", + "desktop": { + "overflow": false, + "breadcrumb": true + }, + "errors": [] +} \ No newline at end of file diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md new file mode 100644 index 00000000..066dface --- /dev/null +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -0,0 +1,73 @@ +# Rastreabilidade, utilização e formação + +## Matriz executável + +| Menu / ecrã | Componente React | Campos / entidade / tabela | Rota ou API | Perfil / regra | Evidência | +| --- | --- | --- | --- | --- | --- | +| Início | `PortalInvestidor` | Sectores, oportunidades, recursos publicados | `/`, APIs existentes de homepage | Visitante; contagens do cadastro | `PublicPortalNavigationTest`, navegador | +| Catálogo / Educação | `investment/catalogue` | Filtros, educação, pivots, facetas | `/oportunidades`, `/educacao`; `/api/v1/investment/search` | Visitante; filtros em intersecção, montantes com moeda | `InvestmentUnificationTest` | +| Cluster / Corredor / Âncora | `portfolio/index`, `portfolio/show` | Dossiers, relações, cadeias e oportunidades | `/clusters`, `/corredores`, `/projectos-ancora`, `/carteira/{id}` | Visitante; dossier publicado; cadeia pública | `InvestmentPortfolioTest`, testes territoriais | +| Ficha | `investment/detail` | Oportunidade, contexto, relações, fonte | `/oportunidades/{slug}`; `/api/v1/investment/opportunities/{slug}` | Visitante; rascunhos não acessíveis | `PublicPortalNavigationTest`, `InvestmentUnificationTest` | +| Mapa | Componente `Map` em catálogo | `geo_locations`, `geo_geometries`, `opportunity_locations` | `/api/v1/investment/map` | Visitante; dois níveis de classificação pública | Teste PostGIS e estados vazios | +| Comparar / exportar / sugerir | `investment/catalogue` | IDs publicados, dados de catálogo | `/compare`, `/export`, `/suggest` sob prefixo da API | Visitante; 4 comparações, 1.000 linhas/pontos; CSV protegido | Teste de publicação e fórmula CSV | +| Área do investidor | `investment/workspace` | Manifestações, recibos, favoritos | `/investidor/manifestacoes`; `/api/v1/investment/submissions` | Titular autenticado e verificado | Isolamento por dono | +| Perfil / empresas | `investment/profile` | `investment_accounts`, `investment_company_declarations` | `/investidor/profile`; API `/profile`, `/companies/{id?}` | Titular; versão, encriptação, sem concessão de memberships | `InvestmentWorkspaceTest` e navegador | +| Conversa / versões | `investment/conversation` | `investment_messages`, `investment_submission_revisions` | API `/submissions/{id}/messages`, `/submissions/{id}/revisions/{version}` | Titular/gestor; mensagem idempotente; histórico privado | Testes de isolamento, encriptação e reenvio; navegador | +| Notificações | `investment/notifications` | `investment_notifications` | `/investidor/notifications`; API `/notifications`, `/{id}/read` | Destinatário; revogação de acesso a terceiros respeitada | Testes de mudança de estado e leitura; navegador | +| Manifestação | `investment/submission` | `investment_submissions.payload`, necessidades | `PUT /submissions/{id}`, `POST /submissions/{id}/submit` | Titular; versão, opções activas, consentimento, idempotência | Testes de conflito, validação e reenvio | +| Anexos | Etapa 4 da manifestação | `documents`, `investment_submission_attachments` | `/submissions/{id}/attachments` | Titular/gestor lê; titular altera rascunho; checksum | Teste de anexo privado e adulteração | +| Contexto da oportunidade | `investment/context` | Perfil, relações muitos-para-muitos, facetas | `/management/opportunities/{id}/context` | Gestor; versão e retorno a rascunho | Teste de gestão, conflito e educação | +| Referências e formulários | `investment/references` | Opções, rótulos, ordem, actividade | `/management/investment/references` | Gestor; códigos estáveis, Outro preservado | Teste de referências e reexecução do seed | +| Atendimento / CRM | `management/interest`, gestão CRM existente | `interest_requests`, `outbox_events`, leads | `/management/interests/{id}` | Gestor; consulta manifestação completa pelo vínculo | Testes de intake e CRM existentes | +| Apoio / documentos / parcerias | `portal/services`, `portal/manage` | Recursos, parcerias, pedidos existentes | `/apoio`, `/documentos`, `/parcerias`, `/balcao-virtual` | Regras existentes de publicação e dono | `PublicPortalServicesTest` | + +Os nomes curtos `/compare`, `/submissions`, etc. nesta matriz pertencem a `/api/v1/investment`. O contrato detalhado está em `openapi.json` (OpenAPI 3.0.3). + +## Cobertura ainda parcial do prompt + +As fichas territoriais apresentam conteúdo autorizado do documento v4, relações e cadeias cadastradas. Não existe ainda um editor unificado de todos os actores, fornecedores, contactos, infraestruturas físicas e indicadores para cada ficha. O mapa actual é de pontos de oportunidades, sem cartografia de linhas de corredores. As facetas representam classificações; não substituem inventário físico ou cálculo de lacunas. + +A educação pública encontra oportunidades publicadas classificadas como educativas ou ligadas a propostas. As propostas geradas pelo módulo interno conservam a regra de publicação desse módulo; a extensão não torna públicas automaticamente necessidades internas ou alegações institucionais. + +A área pessoal reúne rascunhos, manifestações, estados, favoritos, perfil de investimento, empresas declaradas, conversa bidireccional e notificações internas. As declarações empresariais são privadas e não substituem o cadastro canónico validado de organizações. Não existem candidaturas com um procedimento distinto das manifestações. Confirmação e notificações são apresentadas no portal; não se presume envio de email. O CRM local recebe o pedido pela outbox existente, mas a entrega a SuiteCRM remoto continua dependente da configuração e teste do conector. + +O histórico de manifestações enumera actos e versões e permite consultar snapshots privados gravados a partir desta extensão; não restaura automaticamente versões anteriores. A gestão utiliza o gate `manage-portal`; separação de técnico, validador e administrador continua pendente. Os achados anteriores de concorrência/publicação em dossiers e revisão de resultados de IA permanecem registados em `../architecture-data-security-qa-product-review.md`. + +## Manual do investidor + +1. No catálogo, combine sector, território, maturidade, educação e características. Para comparar montantes, seleccione a mesma moeda. Os filtros não convertem câmbio. +2. Alterne entre grelha, lista e mapa. Quando não há coordenadas autorizadas, o portal informa a ausência. Marque até quatro oportunidades para comparação; use Exportar para obter as primeiras mil linhas filtradas. +3. Abra uma ficha e seleccione Preparar manifestação. Entre com conta de email verificado. Nome e email são preenchidos a partir da conta; os restantes dados são declarações do investidor. +4. Preencha identificação e investimento, acrescente necessidades, quantidades e prazos. Escolha Outro e descreva quando nenhuma opção servir. Mão de obra e educação apresentam campos específicos. +5. Guarde o rascunho antes de sair ou carregar anexos. Na etapa Anexos, associe PDF/TXT e identifique o ficheiro nas observações da necessidade correspondente. +6. Reveja, conceda consentimento de contacto e envie. O protocolo identifica um único pedido; repetir o envio não o duplica. Consulte o estado na área pessoal. O envio não constitui aprovação de investimento nem consentimento para IA. +7. Se aparecer conflito de versão, recarregue para ver a versão actual antes de repetir a alteração. Nunca sobrescrever automaticamente uma sessão concorrente. +8. Em Perfil e empresas, guarde dados reutilizáveis e declare as empresas que representa. Preparar manifestação desta empresa preenche os dados declarados, sem atribuir permissões sobre organizações de terceiros. +9. Depois de submeter, use Conversa com o apoio para esclarecimentos. Consulte Notificações para respostas e mudanças de estado; marque-as como lidas. Em Versões guardadas, consulte o conteúdo anterior sem alterar o pedido actual. + +## Manual de gestão + +1. Em Oportunidades, crie ou edite a ficha principal e abra Contexto. Associe clusters, corredores, âncoras e localizações existentes; seleccione educação/facetas quando aplicável e registe a fonte. +2. Guardar contexto retira a publicação até revisão explícita na ficha principal. Confira texto, referências, moeda e ausência de informação privada antes de publicar. +3. Em Referências e formulários, acrescente opções dentro dos grupos existentes. Edite rótulos e ordem; desactive opções obsoletas. Não altere códigos já usados. Outro mantém-se activo e exige especificação. +4. Em Pedidos de interesse, consulte a manifestação completa e os anexos privados. Actualize o estado pelo fluxo existente e encaminhe para o CRM com responsável e próxima acção. +5. Dossiers e oportunidades são registos diferentes. A associação é explícita; não copie as quinze âncoras para o catálogo só para produzir resultados. + +## Design e protótipos executáveis + +Os ecrãs React são os protótipos funcionais entregues: catálogo, detalhe, área pessoal, formulário de cinco etapas, contexto e referências. Não foi produzido um ficheiro Figma nem se alega validação prévia por participantes. A biblioteca local usa fundo slate-50, texto slate-900, acções emerald-800, cartões com borda, campos rotulados, foco visível, grelhas responsivas e estados vazios explícitos. Elementos partilhados: `investment/shared.tsx` e `layouts/portal-navigation.tsx`. + +Verificar teclado, ordem/foco, erros, contraste, zoom 200–400%, leitura assistiva, upload e suporte fixo em cada alteração. A lista textual do mapa é alternativa ao SVG. Verificações automáticas e ausência de transbordo não equivalem a certificação WCAG 2.2 AA. + +## Formação e próximos critérios + +Plano preparado, sessões ainda não realizadas: + +| Sessão | Exercício verificável | Resultado esperado | +| --- | --- | --- | +| Investidor — 30 min | Filtrar, comparar, guardar necessidade, anexar e enviar em ambiente de formação | Um protocolo; nenhum dado visível por outro participante | +| Gestor — 45 min | Criar opção, associar contexto, rever publicação, consultar pedido e encaminhar | Fonte registada, rascunho antes de publicar, encaminhamento com responsável | +| Operação — 45 min | Restaurar backup isolado, verificar chave/anexos, simular falha de fila | Reconciliação documentada; nenhum envio duplicado | +| Acessibilidade — 30 min | Percurso por teclado e leitor de ecrã em móvel/computador | Registo de obstáculos e correcções antes de homologação | + +Backlog restante: editores territoriais completos e cartografia; ligação validada de empresas declaradas ao cadastro canónico; papéis especializados; retenção; homologação assistiva e carga; SuiteCRM no destino; sessões de formação e deploy monitorizado. A reconciliação remota foi adiada pelo proprietário para fase futura. Cada item exige implementação/evidência concreta, não nova aprovação genérica dos requisitos já autorizados. diff --git a/docs/investmoz/unification/validation.md b/docs/investmoz/unification/validation.md new file mode 100644 index 00000000..691f91f1 --- /dev/null +++ b/docs/investmoz/unification/validation.md @@ -0,0 +1,56 @@ +# Validação da extensão — 28/09/2026 + +## Verificações executadas + +- Em 28/09, protecção de versões das fichas territoriais aplicada localmente. Backup `storage/app/private/portfolio-version-backup-20260928-011346/` restaurado e conferido em 93 tabelas; após migration, apenas `migrations` mudou de 34 para 35. As 29 fichas publicadas foram preservadas com `version_no=1`. A suite inclui recusa de gravação/publicação obsoletas com HTTP 409. O teste de navegador anteriormente registado não foi repetido para este novo controlo; cobertura desta alteração por testes HTTP, TypeScript, lint e build. + +- Suite PHP completa: **138 testes, 1.463 verificações aprovadas**, em PostgreSQL/PostGIS isolado na porta 55441, base `portal_services_test`. +- PHPStan completo e verificação TypeScript aprovados; análise focada repetida após as alterações finais. Pint aplicado aos novos ficheiros PHP. Lint dos componentes alterados executado; corrigido tratamento de falha da cópia de ligação. +- Build Vite aprovado. As verificações de PHP não correram durante a substituição do manifesto do build. +- O contrato OpenAPI descreve 27 operações, correspondentes às 25 rotas registadas (referências e empresas com ID opcional correspondem a criar e editar). JSON, referências internas e cobertura de métodos/rotas conferidos; isto não substitui validação completa por uma ferramenta OpenAPI externa. +- Backup anterior à migration restaurado e conferido em 76 tabelas. Depois da migration: 88 tabelas; 114 opções semeadas, restantes tabelas novas vazias. Contagens de negócio anteriores preservadas; `migrations` passou de 32 a 33 e `sessions` de 1 a 2. Manifesto e reconciliação permanecem no directório privado do backup. +- Segundo backup restaurado e conferido nas 88 tabelas antes da extensão de perfil/conversa; após a segunda migration, 93 tabelas e apenas alteração da contagem de `migrations` (33 → 34). +- [Latência local](local-latency.json): cinco pedidos HTTP sequenciais por endpoint, medianas de 170,67 ms na pesquisa, 173,39 ms nas referências e 160,86 ms no mapa. Servidor PHP de desenvolvimento, pequeno cadastro local, sem concorrência; não é SLA nem teste de carga. + +## Navegador + +Microsoft Edge headless via DevTools Protocol, 390×844 e 1440×1000. [Evidência pública](public-browser-validation.json): onze páginas, breadcrumbs, apoio fixo, ausência de transbordo horizontal; cenário aritmético do simulador confirmado. Nenhum erro JavaScript observado. + +[Evidência autenticada](browser-validation.json): dezoito observações de ecrãs/etapas, com contas e oportunidade exclusivamente sintéticas na base isolada. Foram exercitados: + +1. Educação e ficha pública. +2. Login, área pessoal e criação de rascunho. +3. Identificação, participação, objectivo e necessidade educativa com quantidade/unidade. +4. Gravação preservando a etapa actual. +5. Upload privado pela API multipart, com versão incrementada. +6. Revisão, consentimento, submissão e protocolo visível na área pessoal. +7. Reenvio idempotente com o mesmo protocolo. +8. Login de gestor, referências, edição de contexto e retirada da publicação. +9. Leitura do pedido pelo gestor e recusa 404 a outro investidor. +10. Perfil e empresa declarada, pré-preenchimento da manifestação, conversa investidor/apoio, consulta de versão histórica e marcação de notificação como lida. +11. Recusa 419 de pedido cross-site com sessão mas sem token. O Laravel 13 aceita pedidos same-origin pelo cabeçalho de origem validado; esse comportamento foi conferido no middleware instalado, não confundido com desactivação da protecção. + +Não foram detectados campos visíveis sem rótulo, transbordo horizontal ou erros JavaScript nesses ecrãs. A primeira tentativa usou uma porta ocupada por outro servidor PHP sem driver PostgreSQL; o teste foi repetido com servidor dedicado na porta 18027. A fixture de QA foi corrigida para marcar explicitamente o email verificado, sem alterar utilizadores operacionais. + +O upload foi exercitado pela API a partir da sessão real do navegador; o selector nativo de ficheiros não foi automatizado. As verificações de autorizações complementam os testes HTTP, que cobrem adulteração de checksum, opções de grupo errado, ausência de consentimento, retirada de publicação, conflito de versão, relações territoriais cumulativas, CSV e isolamento. + +## Repetição automatizada + +Com uma base descartável terminada em `_test`, PostgreSQL/PostGIS e variáveis `DB_*` explícitas: + +```sh +php vendor/bin/phpunit --filter 'InvestmentUnification|InvestmentPortfolio|PublicPortalNavigation' --do-not-cache-result +php vendor/bin/phpunit --filter InvestmentWorkspace --do-not-cache-result +php vendor/bin/phpunit --do-not-cache-result +php vendor/bin/phpstan analyse --no-progress --memory-limit=512M +npm run types:check +npm run build +``` + +`RefreshDatabase` recria tabelas no ambiente de teste. Não executar esses comandos de teste contra a base operacional. Em PowerShell com restrições a scripts, usar `npm.cmd`. + +## Limites + +Os ensaios não demonstram WCAG 2.2 AA integral, experiência com leitor de ecrã, carga concorrente, desempenho de produção, entrega de notificações ou SuiteCRM externo, nem integridade da base remota ainda inacessível. O mapa foi validado com pontos sintéticos nos testes PostGIS e estado vazio quando não há coordenadas públicas. Não foi desenhada cartografia fictícia para preencher a interface. + +A aprovação de requisitos pelo proprietário foi respeitada. Homologação com participantes, formação, deploy remoto e monitorização no destino continuam por executar; não são etapas que se possam declarar aprovadas apenas com esta evidência local. diff --git a/docs/investmoz/upgrade-progress.md b/docs/investmoz/upgrade-progress.md new file mode 100644 index 00000000..6ca5fcae --- /dev/null +++ b/docs/investmoz/upgrade-progress.md @@ -0,0 +1,102 @@ +# Progresso do upgrade — 26 de Setembro de 2026 + +## Proposta: Formação Técnica, Competências e Investimento em Educação + +**Camada assistiva — 26/09/2026:** [base interna de assistência activada localmente](ai/README.md), com gateway Laravel, fontes/fragmentos FTS, regras, resultados encriptados, feedback, revisão independente e suspensão. 118 testes/1.080 verificações aprovadas; oito casos lexicais sintéticos PT/EN medidos. Sem geração/embeddings/reranker activos; o benchmark dos modelos e pgvector exigem infraestrutura. [Cobertura e limites](ai/coverage.md). + +**Actualização Education — 26/09/2026:** [decisões técnicas validadas e executadas localmente](education/decisions.md). Implementadas fichas educativas em rascunho ligadas ao catálogo canónico, protecção da publicação, filtros e indicadores internos. Duas migrations aplicadas após backup com restauro verificado e reconciliação de 66 tabelas. Módulo activado apenas na instalação local, mantendo os gestores existentes. Suite PHP: 109 testes/961 verificações; TypeScript, build e análise estática aprovados. [Cobertura e lacunas por RF](education/implementation-status.md). Validação institucional, integrações externas e produção continuam pendentes. + +Entrega inicial: preparado o [pacote de concepção Education](education/README.md), conforme o nível de implementação inicialmente autorizado no novo pedido: arquitectura, modelos, contratos propostos, migração não executável e exemplos para revisão. Inclui 30 secções, 33 RF (preservando RF-EDU-001–016), 18 RNF, 44 entidades, 25 indicadores, RACI, segregação, IA, GIS, propriedade dos dados, fases, riscos e 53 itens de conformidade. + +Diagnóstico read-only confirmou seis tabelas Education vazias e lacunas entre `training_needs`, oferta calendarizada e investimento educativo. Nenhuma tabela, rota ou configuração da aplicação foi alterada. Onze testes dos exemplos sintéticos e da consistência documental passaram. Não são testes de implementação do módulo: RF-EDU continua pendente funcionalmente. Aprovação dos pilotos, fontes, mandatos, modelo canónico, privacidade e contratos condiciona a integração, conforme bloqueios B01–B08 da proposta. + +Este registo complementa a auditoria em `requirements-audit.md`. O upgrade integral continua em execução: as entregas abaixo não significam cumprimento completo das famílias RF-CRM, RF-CLU ou RF-PII. + +## Entrega: entrada e acompanhamento inicial no CRM + +- Conversão explícita de pedidos consentidos em leads, com responsável autorizado e próxima acção com prazo futuro. +- Conversão idempotente por pedido; preservação da referência de origem. +- Reutilização de contactos por email normalizado; selecção explícita do investidor quando o contacto já tem vínculo. Duplicados preexistentes exigem revisão. +- Directório e edição de investidores e contactos; verificação do vínculo antes de editar um contacto. +- Qualificação/desqualificação humana com justificação; tarefas, prazos e indicação de atrasos. Leads activos precisam de próxima acção antes de encerrar a última tarefa. +- Auditoria transaccional, identificação UUID e protecção das rotas pelo gestor verificado. + +Evidência: `CrmController`, `ConvertInterestToLead`, páginas `crm`, `CrmIntakeTest`. Migração aditiva `2026_09_25_100000_extend_crm_intake` aplicada à base local sem recriação de tabelas. + +Cobertura parcial: RF-CRM-001, 002, 003, 005 e 006. Ainda faltam fusão com NUIT, perfis completos, atribuição multicanal, scoring configurável, pipeline de negociação, cadências, relações comerciais adicionais, previsões, dashboards analíticos, revogação de consentimento e passagem a aftercare. A justificação actual não constitui um histórico completo de decisões. + +## Entrega: gestão interna de projectos e clusters + +- Pesquisa, criação e edição de projectos e clusters com validação de códigos únicos. +- Orçamentos decimais exactos, moeda obrigatória com montante e coerência das datas dos projectos. +- Associação idempotente de projectos a clusters como âncora, complementar ou apoio, com alteração auditada do papel. +- Criação e edição de etapas da cadeia de valor: insumos, produção, transformação, logística e mercado; códigos únicos por cluster e bloqueio de edição cruzada. +- Registos internos: estado activo não equivale a aprovação nem publicação institucional. + +Evidência: `InvestmentStructureController`, páginas `structures`, `InvestmentStructureTest`. Utiliza as tabelas existentes sem substituir as migrations de base. + +Cobertura parcial de RF-CLU-001, 002 e 003. Ainda faltam taxonomia versionada com aprovação, actores/territórios, mapas e comparação, matching, programas, dossiers públicos, campanhas, métricas e governação institucional. O cadastro de projectos não substitui o ciclo completo de investimento RF-PII. + +## Entrega: ligações e lacunas das cadeias de valor + +- Ligações dirigidas entre etapas do mesmo cluster: fornecimento, transformação, distribuição e apoio. +- Criação idempotente; rejeição de ligações a si próprias, etapas eliminadas ou pertencentes a outro cluster. +- Retirada lógica de ligações, preservando os registos e a auditoria. Uma nova ligação pode ser criada após a retirada. +- Lacunas associadas a uma etapa, com título, localização descritiva, capacidade necessária, justificação, estado e chamada para acção obrigatórios. +- Consulta, edição, resolução e retirada de lacunas; indicação da quantidade ainda por resolver. +- Protecção de acesso e edição por cluster, com auditoria transaccional sem copiar os textos livres para os eventos. + +Evidência: `ClusterValueChainController`, `structures/value-network.tsx` e `ClusterValueChainTest`. Migração aditiva `2026_09_25_110000_create_investment_gaps` aplicada à base local; reversão e reaplicação verificadas apenas na base de testes. + +RF-CLU-003 permanece parcialmente cumprido: a localização é descritiva, sem associação GIS; serviços ainda não têm uma fase própria na taxonomia existente. A chamada para acção é texto interno, sem fluxo público de candidatura ou validação institucional. As ligações são apresentadas numa lista dirigida, sem visualização geográfica. + +## Verificação + +- 98 testes PHP, 748 asserções: portal em PostgreSQL/PostGIS isolado e cliente SuiteCRM com HTTP simulado. +- 13 testes frontend aprovados; build de produção concluído. +- Ciclo do esquema de base de 24 de Setembro: 3.106 asserções. O manifesto histórico é testado apenas contra as migrations desse esquema; extensões posteriores são verificadas pelos testes funcionais. +- PHPStan, TypeScript, lint e formatação sem erros. +- CRM verificado em Edge: autenticação, conversão, responsável, tarefa, origem, persistência e apresentação móvel. +- Projectos e clusters verificados em Edge: orçamento exacto após recarregar, criação, associação, etapas, persistência e apresentação móvel. +- Cadeias de valor verificadas em Edge: ligação e retirada, criação de lacuna, persistência dos detalhes, resolução e apresentação móvel. +- CI configurado com PHP 8.5 e serviço PostGIS; execução remota ainda não verificada. + +## Trabalho ainda necessário + +### Decisão de integração SuiteCRM + +O utilizador confirmou que SuiteCRM e OAuth ainda não estão instalados e delegou a escolha técnica. Decisão: SuiteCRM como sistema principal comercial após migração validada; portal mantém cadastro de investimento, GIS, submissões e autenticação. Não haverá escrita dupla de contactos, leads ou tarefas. A gestão local continua até ao corte controlado. + +Preparados [arquitectura e plano](suitecrm/architecture.md) e [modelo Docker de homologação](../../deploy/suitecrm/README.md) para SuiteCRM 8.10.2/PHP 8.4/MariaDB 11.4. Pacote oficial descarregado e SHA-256 verificado; imagens identificadas por digest. Docker e WSL não estão instalados neste Windows: build, arranque, conta técnica, OAuth e integração real continuam pendentes. Esta preparação não altera a base de dados ou os fluxos existentes. + +Acrescentado o [cliente Laravel SuiteCRM](suitecrm/client.md), desactivado por defeito: OAuth client credentials, cache cifrado, renovação limitada, operações JSON:API, retries de consulta e circuit breaker. Escritas ambíguas não são repetidas automaticamente. Foram aprovados 15 testes com HTTP simulado (62 asserções) e PHPStan. Nenhuma chamada foi feita a um CRM real; envio da outbox, sincronização funcional, upload binário e reconciliação continuam pendentes. + +### Entrega: submissões idempotentes e captura na outbox + +O formulário reutiliza uma chave UUID nas novas tentativas do mesmo pedido enquanto a página permanece aberta. A API devolve a confirmação original para a mesma chave e conteúdo, e rejeita alterações de conteúdo com HTTP 409. Clientes sem chave continuam compatíveis, mas sem garantia de deduplicação. + +Pedido, recibo de idempotência, notificação em fila e evento `ExpressionOfInterestSubmitted` são gravados na mesma transacção. O evento contém apenas a versão do contrato e o UUID do pedido, sem copiar dados pessoais. Os eventos permanecem pendentes: ainda não existe envio ao SuiteCRM. Contrato e limites em [outbox e idempotência](suitecrm/outbox.md). + +Migração `2026_09_25_120000_add_interest_submission_receipts` aplicada à base local. Reversão sem dados e reaplicação verificadas na base isolada; a reversão com identidades capturadas é bloqueada e coberta por teste. Em Edge, uma resposta perdida após gravação foi seguida de nova tentativa com a mesma chave e confirmação original. A chave não persiste após recarregar a página. + +A matriz `requirements-audit.csv` continua a ser a referência de âmbito. Permanecem os requisitos não cobertos de CRM, clusters, corredores, GIS, ciclo do investidor/aftercare, empresas, educação, monetização, integrações, contratos de API/eventos, PT/EN e requisitos não funcionais. + +Não foram escolhidos pesos de scoring, fornecedor de IA, fontes institucionais autorizadas ou perfis por organização sem decisão documentada. Esses parâmetros devem ser configuráveis; não se deve apresentar dados ou recomendações como validados antes dessa configuração e revisão humana. + +A conta `comercial@tempo.co.mz` permanece por configurar, conforme solicitado. Não foram enviados emails nem efectuado deployment remoto. + +### Preparação da instalação local SuiteCRM + +O utilizador escolheu instalação local. Diagnóstico: Windows ARM64 em Parallels, sem motor Docker, WSL ausente e virtualização não exposta; tentativa de instalação WSL falhou. Preparados scripts de arranque para Windows compatível e macOS/Linux, preservando volumes e segredos existentes, e [instruções locais](../../deploy/suitecrm/LOCAL.md). Neste equipamento recomenda-se executar Docker no macOS anfitrião. Instalação efectiva e afinação com medições continuam bloqueadas pela ausência do motor acessível. + +Acrescentado `suitecrm:probe`: OAuth e leitura de metadados sem escrita comercial nem exposição de tokens. Dois novos testes (6 asserções) e PHPStan passaram; a suite completa de 91 testes da entrega anterior não foi repetida nesta alteração. O diagnóstico local confirma integração desactivada. Scripts de contentores ainda não executados; script PowerShell analisado e verificado no cenário de Docker ausente. + +## Entrega: matriz territorial e início da jornada integrada + +Importados **6 corredores, 8 clusters e 15 projectos âncora** das secções 4.3.4, 4.5.2, 6.1 e 6.6 do documento. Os 29 dossiers ficam em rascunho, com fontes e relações canónicas, sem divulgação automática de estimativas ou mapas. Migração `2026_09_25_130000_investment_portfolio_journeys` e seeder aplicados à base local; rollback vazio e reaplicação verificados apenas na base isolada. + +Gestão em `/management/portfolio`, consulta de fichas publicadas em `/carteira` e painel individual em `/investidor`. Revisão por outro gestor, evidência de autorização, retirada da publicação ao editar, relações bidireccionais e contexto directo de origem preservado. O investidor guarda fichas, manifesta interesse com consentimento e acompanha o protocolo. Conversão em lead mantém a identidade da jornada e continua a exigir responsável e próxima acção. + +Validado no Edge: corredor → projecto → painel → consentimento → pedido → persistência após recarregar, matriz e largura móvel. PHPStan, TypeScript, lint, formatação e build passaram. Os 98 testes incluem os cinco novos testes da carteira e jornada. + +Cobertura ainda parcial de RF-COR, RF-CLU e RF-EII. [Âmbito, fontes e pendências](portfolio-journey.md): GIS, governação, comparação, cofre documental, representação organizacional, aftercare, IA e integração real do SuiteCRM não estão concluídos. diff --git a/docs/investmoz/validation-results.json b/docs/investmoz/validation-results.json new file mode 100644 index 00000000..440b51c7 --- /dev/null +++ b/docs/investmoz/validation-results.json @@ -0,0 +1,94 @@ +{ + "date": "2026-09-24", + "environment": "Isolated temporary local PostgreSQL instance, loopback port 55439; application database untouched", + "versions": { + "php": "8.5.11", + "postgresql": "17.11", + "postgis": "3.6.2" + }, + "schema": { + "application_tables": 59, + "new_tables": 47, + "legacy_tables": 12, + "columns": 684, + "foreign_keys": 69, + "new_migrations": 15, + "framework_tables": [ + "migrations" + ], + "extension_tables": [ + "spatial_ref_sys" + ] + }, + "checks": { + "schema_lifecycle": { + "tests": 1, + "assertions": 3106, + "result": "passed" + }, + "application_suite": { + "tests": 39, + "assertions": 136, + "result": "passed" + }, + "pint_changed_php": "passed", + "phpstan_changed_models_factories_seeders": "passed, zero errors", + "php_syntax": { + "files": 22, + "result": "passed" + }, + "generator_reproduction": "43 generated files reproduce exactly", + "static_consistency": "passed" + }, + "confirmed": [ + "migrate:fresh --seed", + "migrate ledger idempotency", + "reference seed idempotency", + "rollback of 15 new migrations preserves legacy", + "ordinary rollback of the latest upgrade batch", + "reapply after rollback", + "extensions retained", + "PostGIS type/SRID/validity/empty/bounds checks", + "monetary precision and checks", + "FK actions and referential integrity", + "append-only audit and immutable event envelopes", + "exact catalogue columns/types/nullability/defaults/index set", + "GiST index eligibility" + ], + "not_confirmed": [ + "Production readiness", + "RLS and application permission matrix pending P-05", + "Real MySQL import and reconciliation", + "Latency SLO at representative volume", + "Retention/purge policy", + "Business domains without complete DDL" + ], + "tested_artifact_sha256": { + "database/migrations/2026_09_24_000100_investmoz_extensions_functions.php": "3daa494165853ba673f99012ed5651cfecfba153c676ae4b7099b9e6596ade38", + "database/migrations/2026_09_24_001000_investmoz_identity.php": "f644b0369b17b2954b05f563a569d92533ae1bb06f89bf1cc8454d069b733b96", + "database/migrations/2026_09_24_002000_investmoz_content.php": "9f3006caa7092eb3651033d7756982b176a54c79ac47a9cf9a67c306e369a81c", + "database/migrations/2026_09_24_003000_investmoz_geography.php": "405a75432fb9b90b4472c697a9239fc8d59907da8defbb985cf81cd3da958ee6", + "database/migrations/2026_09_24_004000_investmoz_projects.php": "7b7f652cf323299c346133e7fda6564722eab60e8bba193387f8fa2af8bf2d35", + "database/migrations/2026_09_24_005000_investmoz_clusters.php": "93dc9bff75f759fc209f00b1bbb2d6727fbf4d3014fa368db394254789ad7df1", + "database/migrations/2026_09_24_006000_investmoz_corridors.php": "8061e16466dfd505785bdd233851216cd01a62397d144da2f9c996a15b5481cf", + "database/migrations/2026_09_24_007000_investmoz_crm.php": "ef49c4e0b15f1a0e2eaccc6c60b578e88a616aeb42e3a420865bb29aa83009a8", + "database/migrations/2026_09_24_008000_investmoz_journey_aftercare.php": "f64af9031e071ff30b94df64c13f507800928d3bb54e5d7d0bd6a8f2c495cd51", + "database/migrations/2026_09_24_009000_investmoz_education.php": "fa6245b9a0a07dadd3512099450230919cfaa134c98b81c6362fb76abc9a3ea0", + "database/migrations/2026_09_24_009500_investmoz_document_links.php": "68601d5dec9410609ae34050b137b46af3d8e09e3510a331aa824fc58d01ed9f", + "database/migrations/2026_09_24_010000_investmoz_governance_ai.php": "c66ce3cf351a97f164c325c87941260a448b2b301ed037d251e0562143e48f85", + "database/migrations/2026_09_24_011000_investmoz_integrations.php": "3bab367d76ae5d3a9ef5836a95ddc41d01b35d24d119c7f1380b9153f4055b38", + "database/migrations/2026_09_24_012000_investmoz_indexes.php": "ff069e7a2cd518b104671bb2879444aba1df504fce6664cd61cce80038901eca", + "database/migrations/2026_09_24_013000_investmoz_triggers.php": "a41f8563aada3f3dba8882e67c3647fd1e9edc88f11b382af59399d380f6b83e", + "app/Models/Project.php": "d5831329ebc47ce8e367159b76817a612bf57ab07135aefad80f836aa362f0ac", + "app/Models/Investor.php": "aba61fa85e074db4aab1973c91a2a476ce88f43d91f1c1ef8c471f4c8d9d3fc4", + "database/factories/ProjectFactory.php": "e8851a5ce4751e8d34d3f50d8ef3d103c76c88b8278fe0097750944aabf88080", + "database/factories/InvestorFactory.php": "38db8127c708d1dcd41e9ffbce27593166156d050a082b60549e963e2499b585", + "database/seeders/InvestMozReferenceSeeder.php": "b8cefc04fe38b8f1d8c40fc527e9671a2fc34a80557165f1675a03b1ad45ff93", + "database/seeders/DatabaseSeeder.php": "5a5aedf020f281a6749e62f90e5fe6235f6b99236641bffff9049e6080454583", + "tests/InvestMoz/SchemaLifecycleTest.php": "ab3c25168dd27bae1a6f03f0dde77012d60be44fa799d96487a63c6a3a735fdd", + "phpunit.xml": "69c7c7391339eacad36e73f656232b2a3492f6826915d4b3c938fc0bff4585dc", + "phpunit.investmoz.xml": "0444d3d575846a772bca1c1e791f78bfc351e5d84200238bb3251c11f366c2e9", + "docs/investmoz/schema.json": "8881545beb64f48c7c1afe97d24744054bccfe9f698861d809c8e714a3bb29c4", + "docs/investmoz/legacy-schema.json": "4c6982432c7451d144ae1b03a8291e7796c977f698c8b809b57824aacf5d3629" + } +} diff --git a/docs/investmoz/validation.md b/docs/investmoz/validation.md new file mode 100644 index 00000000..3c22c873 --- /dev/null +++ b/docs/investmoz/validation.md @@ -0,0 +1,40 @@ +# Evidencias de validacao local — 24/09/2026 + +Resultado: 40 testes passaram (39 da aplicacao + 1 de ciclo de esquema), com 3.242 verificacoes no total. +Ambiente isolado: PHP 8.5.11, PostgreSQL 17.11, PostGIS 3.6.2, porta local 55439. A base configurada da aplicacao nao foi utilizada. + +| Verificacao | Resultado | +|---|---| +| migrate:fresh --seed | Passou | +| Segunda execucao de migrate e seeds de referencia | Passou | +| Rollback das 15 novas migrations preservando legado | Passou | +| migrate:rollback do ultimo lote e reaplicacao | Passou | +| Preservacao de postgis/pgcrypto | Passou | +| 59 tabelas, 684 campos, tipos, defaults, nulabilidade e indices | Correspondem ao catalogo | +| 69 FKs e respectivas accoes | Correspondem ao dicionario | +| Restricoes monetarias, espaciais, auditoria e eventos | Passaram | +| PHPUnit dedicado | 1 teste, 3.106 verificacoes | +| PHPUnit da aplicacao | 39 testes, 136 verificacoes | +| PHP lint | 22 ficheiros passaram | +| Pint dos ficheiros alterados | Passou | +| PHPStan dos modelos/factories/seeders alterados | Passou, zero erros | +| Consistencia estatica e reproducao do gerador | Passaram | + +A base tem 61 tabelas fisicas: 59 da aplicacao, migrations gerida pelo Laravel e spatial_ref_sys gerida pelo PostGIS. Os dois objectos de infraestrutura nao sao criados pelas migrations aplicacionais. +Os hashes dos artefactos testados estao em validation-results.json. A validacao corresponde a esses ficheiros; qualquer alteracao posterior exige nova verificacao. + +Comandos usados com variaveis de ambiente apontadas exclusivamente para a instancia temporaria: + + php vendor/bin/phpunit --configuration=phpunit.investmoz.xml --do-not-cache-result + php vendor/bin/phpunit --configuration=phpunit.xml --do-not-cache-result + php vendor/bin/pint --test [ficheiros PHP alterados] + php vendor/bin/phpstan analyse [modelos/factories/seeders alterados] --no-progress + python tools/investmoz/validate.py + python tools/investmoz/generate.py --source CAMINHO_DOCX --check + +O teste dedicado invoca os comandos Artisan reais dentro da aplicacao. Exige INVESTMOZ_SCHEMA_TESTS=1, APP_ENV=testing, driver pgsql, base *_test e ausencia de config em cache antes de migrate:fresh. +O teste de plano confirma utilizacao possivel do indice GiST; nao prova um SLO de latencia. Nao se validaram producao, importacao real MySQL, RLS nem capacidades funcionais pendentes. + +Compatibilidade local: o PHP 8.1 do Laragon nao satisfaz o composer.lock instalado (PHP >=8.4.1). Durante a analise, a configuracao local indicou a ligacao inexistente postgre; usar DB_CONNECTION=pgsql quando configurar a execucao local. Nao foram alteradas credenciais nem o ficheiro .env. + +Foram corrigidas duas falhas detectadas no ensaio: duplicacao do utilizador pelo seeder antigo e sobrevivencia das funcoes PostgreSQL entre execucoes de migrate:fresh. O seeder passou a firstOrCreate; apenas as funcoes proprias investmoz_* usam CREATE OR REPLACE. diff --git a/docs/investmoz/verification.sql b/docs/investmoz/verification.sql new file mode 100644 index 00000000..daea68df --- /dev/null +++ b/docs/investmoz/verification.sql @@ -0,0 +1,22 @@ +-- Read-only verification after migrate, in isolated PostgreSQL. +SELECT version(), PostGIS_Full_Version(); +SELECT extname,extversion FROM pg_extension WHERE extname IN ('postgis','pgcrypto'); +SELECT table_name,column_name,data_type,udt_name,is_nullable,column_default, + character_maximum_length,numeric_precision,numeric_scale +FROM information_schema.columns WHERE table_schema='public' ORDER BY table_name,ordinal_position; +SELECT conrelid::regclass,conname,contype,pg_get_constraintdef(oid) +FROM pg_constraint WHERE connamespace='public'::regnamespace ORDER BY 1,2; +SELECT tablename,indexname,indexdef FROM pg_indexes WHERE schemaname='public' ORDER BY 1,2; +SELECT tgrelid::regclass,tgname,pg_get_triggerdef(oid) +FROM pg_trigger WHERE NOT tgisinternal AND tgname LIKE 'imz_%' ORDER BY 1,2; +SELECT * FROM pg_policies WHERE schemaname='public'; +SELECT id FROM geo_geometries WHERE ST_SRID(geom)<>4326 OR ST_NDims(geom)<>2 OR ST_IsEmpty(geom) OR NOT ST_IsValid(geom); +SELECT p.id FROM project_locations p LEFT JOIN projects t ON t.id=p.project_id WHERE t.id IS NULL; +SELECT c.investor_id,c.contact_id FROM investor_contacts c LEFT JOIN investors i ON i.id=c.investor_id WHERE i.id IS NULL; +SELECT source_system,external_id,count(*) FROM projects +WHERE external_id IS NOT NULL GROUP BY source_system,external_id HAVING count(*)>1; +SELECT id FROM projects WHERE budget_amount<0 OR ((budget_amount IS NULL)<>(budget_currency IS NULL)); +EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) +SELECT id FROM outbox_events WHERE status='pending' AND available_at<=CURRENT_TIMESTAMP ORDER BY available_at LIMIT 100; +EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) +SELECT id FROM geo_geometries WHERE ST_Intersects(geom,ST_MakeEnvelope(32,-26,33,-25,4326)); diff --git a/docs/investmoz/zones/README.md b/docs/investmoz/zones/README.md new file mode 100644 index 00000000..59f89ca0 --- /dev/null +++ b/docs/investmoz/zones/README.md @@ -0,0 +1,30 @@ +# ZEE, ZFI e catálogo integrado + +Data: 28/09/2026. Fonte de requisitos: «Implementação Integrada de ZEE, ZFI e Revisão do Catálogo de Investimentos», anexo 23b033e6-9ee3-4a6c-a41b-688fe7f09795. Baseline e núcleo funcional local implementados: cadastro, fontes, associações, revisão GIS, pesquisa e contexto de interesse. Nenhuma zona real foi inventada ou publicada. A matriz de estado identifica requisitos ainda por implementar/homologar. + +Actualização de autorização: em 28/09/2026 o proprietário autorizou continuar automaticamente para as fases seguintes. Esta instrução substitui a paragem obrigatória entre fases do ponto 20. Os gates passam a verificações técnicas registadas; produção, actos institucionais e integrações externas reais não são autorizados por esta progressão automática. + +- [Diagnóstico e decisão arquitectural](diagnosis-and-adr.md) +- [Plano por ficheiro e rastreabilidade](plan-and-traceability.md) +- [Baseline, comandos, evidências e gate](baseline.md) +- [Inventário agregado reproduzível](baseline-data.json) +- [Implementação, migrations e limites actuais](implementation-status.md) +- [Dicionário e relações ER](data-dictionary.md) +- [Fontes jurídicas por conferir](legal-matrix.md) +- [Contrato das APIs v2 implementadas](openapi.json) +- [Validação actual e limites](validation.md) +- [Manual administrativo e operacional](operations.md) + +## Escopo piloto proposto para aprovação + +Catálogo local com identidade canónica, relações tipadas entre investimentos, clusters, corredores, ZEE, ZFI e parques; descoberta nas quatro dimensões; fichas PT/EN; administração com validação independente e histórico; GIS publicado apenas com evidência; manifestação assistida reutilizando o CRM interno. Identificadores, URLs e API v1 continuam compatíveis. + +Nacala, Mocuba, Manga–Mungassa, Crusse e Jamali, Beluluane, Locone e Minhuene são referências fornecidas no documento, ainda sem dossier oficial validado neste trabalho. Não são sementes publicáveis. Crusse e Jamali conserva categoria por definir; Beluluane não recebe automaticamente estatuto de ZFI. Referências sectoriais e entidades mencionadas no anexo também exigem confirmação antes de publicação como competentes. + +Não entram no piloto: decisão administrativa automática, cálculo de benefício sem regra validada, integração real com Alfândegas, publicação jurídica sem validação, produção ou consolidação da outra máquina. IA continua assistiva e não atribui elegibilidade ou autorização. Mercado de negócios existentes usa o mesmo catálogo com tipo e confidencialidade próprios, sem expor documentos comerciais reservados. + +## Gate + +**Execução local autorizada, em curso.** O piloto proposto serve de âmbito da implementação. Para os novos domínios aplica-se o requisito mais recente: ninguém aprova o próprio registo. Responsáveis nominais de Dados, GIS e Jurídico permanecem por designar; não se presume validação técnica/jurídica a partir de autorização de implementação. Os controlos antigos das fichas territoriais serão tratados separadamente, preservando as aprovações históricas. + +As quatro entradas e os filtros ZEE/ZFI estão ligados ao catálogo existente; testes sintéticos confirmam a mesma oportunidade em duas zonas sem duplicação e uma âncora ligada a ZEE, ZFI e parque. Descoberta indirecta por cadeias/nós e homologação com dados oficiais permanecem pendentes. Nenhuma fase de produção foi iniciada. diff --git a/docs/investmoz/zones/baseline-data.json b/docs/investmoz/zones/baseline-data.json new file mode 100644 index 00000000..139a345f --- /dev/null +++ b/docs/investmoz/zones/baseline-data.json @@ -0,0 +1,113 @@ +{ + "generated_at": "2026-09-28T01:48:22+00:00", + "php": "8.5.11", + "postgres": "17.11", + "postgis": "3.6.2", + "table_counts": { + "activities": 0, + "aftercare_documents": 0, + "aftercare_plans": 0, + "ai_chunks": 0, + "ai_controls": 7, + "ai_feedback": 0, + "ai_runs": 0, + "ai_sources": 0, + "anchor_project_links": 0, + "audit_events": 29, + "cache": 10, + "cache_locks": 0, + "cluster_projects": 13, + "complementary_project_links": 0, + "consents": 0, + "constraints": 0, + "contacts": 0, + "corridor_clusters": 20, + "corridor_locations": 0, + "corridor_projects": 17, + "courses": 0, + "crm_opportunities": 0, + "development_corridors": 6, + "documents": 0, + "economic_clusters": 8, + "education_documents": 0, + "education_investment_proposals": 0, + "education_opportunities": 0, + "escalations": 0, + "failed_jobs": 0, + "geo_geometries": 0, + "geo_locations": 0, + "inbox_events": 0, + "interest_requests": 0, + "interest_submission_receipts": 0, + "investment_accounts": 0, + "investment_company_declarations": 0, + "investment_dossiers": 29, + "investment_gaps": 0, + "investment_messages": 0, + "investment_notifications": 0, + "investment_reference_options": 114, + "investment_specific_needs": 0, + "investment_submission_attachments": 0, + "investment_submission_events": 0, + "investment_submission_revisions": 0, + "investment_submissions": 0, + "investor_accounts": 0, + "investor_contacts": 0, + "investor_documents": 0, + "investor_journeys": 0, + "investors": 1, + "job_batches": 0, + "jobs": 0, + "journey_documents": 0, + "journey_steps": 0, + "labour_demands": 0, + "leads": 0, + "memberships": 0, + "migrations": 35, + "occupations": 0, + "opportunities": 6, + "opportunity_clusters": 0, + "opportunity_corridors": 0, + "opportunity_facets": 0, + "opportunity_favourites": 0, + "opportunity_locations": 0, + "opportunity_profiles": 0, + "opportunity_projects": 0, + "organizations": 0, + "outbox_events": 0, + "passkeys": 0, + "password_reset_tokens": 0, + "portal_partnerships": 0, + "portal_resources": 0, + "project_documents": 0, + "project_locations": 0, + "projects": 16, + "reconciliation_issues": 0, + "sectors": 6, + "sessions": 21, + "skills": 0, + "spatial_ref_sys": 8500, + "submissions": 0, + "sync_runs": 0, + "tasks": 0, + "training_capacities": 0, + "training_needs": 0, + "training_providers": 0, + "users": 1, + "utilizadores": 0, + "value_chain_links": 0, + "value_chains": 0 + }, + "checks": { + "invalid_geometries": 0, + "duplicate_opportunity_references": 0, + "opportunities_without_promoter": 0, + "opportunities_without_profile_source": 6, + "projects_without_description": 1 + }, + "limits": [ + "No semantic deduplication or legal verification performed.", + "Empty geometry inventory does not demonstrate official perimeter coverage.", + "Document orphan and project promoter checks require explicit ownership mapping in phase 1." + ] +} diff --git a/docs/investmoz/zones/baseline.md b/docs/investmoz/zones/baseline.md new file mode 100644 index 00000000..5283799d --- /dev/null +++ b/docs/investmoz/zones/baseline.md @@ -0,0 +1,77 @@ +# Baseline e gate — 28/09/2026 + +Registo histórico da Fase 0. O proprietário autorizou depois a progressão automática; o gate de aprovação abaixo foi ultrapassado por essa instrução. Consultar `implementation-status.md` e `validation.md` para a execução e métricas actuais. + +## Execução e resultados + +| Verificação executada | Resultado | +| --- | --- | +| PHPUnit completo em PostgreSQL/PostGIS isolado | 138 testes, 1.463 verificações, todos aprovados; 27,943 s | +| PHPStan completo | Zero erros | +| TypeScript `--noEmit` | Aprovado | +| Lint páginas investment/portfolio e PortalNavigation | Aprovado | +| Pint do script de inventário | Primeira verificação encontrou estilo; formatado; verificação final aprovada, assim como `php -l` | +| Build Vite | Aprovado, 2.354 módulos, 11,78 s; aviso de dependência opcional fontaine, sem falha | +| Inventário agregado | Executado em transacção READ ONLY/REPEATABLE READ; 93 tabelas; JSON adjacente | +| Backup da base | SHA-256 conferido; manifesto regista restauro verificado em 93 tabelas | + +Backup privado da base: `storage/app/private/portfolio-version-backup-20260928-011346/`. O backup antecede a migration de versão editorial; a reconciliação existente documenta apenas `migrations` 34→35. Backup completo anterior da aplicação: `C:/laragon/backups/portal-investidor-20260927-101224/RESTAURO.md`. Não publicar estes backups nem as configurações privadas. Esta fase não fez alterações de esquema ou conteúdo de negócio. + +As versões instaladas foram lidas de `composer.lock` e dos pacotes em `node_modules`; as versões PostgreSQL/PostGIS foram consultadas na base local. O JSON contém contagens, nunca registos pessoais ou credenciais. + +## Comandos reproduzíveis (PowerShell) + +```powershell +$validationPhp = 'C:/Users/macbook/AppData/Local/Temp/investmoz-validation-20260924/php/php.exe' +& $validationPhp tools/zones-baseline.php +& $validationPhp vendor/bin/phpstan analyse --no-progress --memory-limit=512M +& $validationPhp vendor/bin/pint --test tools/zones-baseline.php +node node_modules/typescript/bin/tsc --noEmit +node_modules/.bin/vp.cmd lint resources/js/pages/investment resources/js/pages/portfolio resources/js/components/PortalNavigation.tsx +``` + +Em outra sessão, exclusivamente para testes, com PostgreSQL QA disponível na porta 55441: + +```powershell +$validationPhp = 'C:/Users/macbook/AppData/Local/Temp/investmoz-validation-20260924/php/php.exe' +$env:DB_CONNECTION='pgsql' +$env:DB_DATABASE='portal_services_test' +$env:DB_HOST='127.0.0.1' +$env:DB_PORT='55441' +$env:DB_USERNAME='education_test' +$env:DB_URL='' +& $validationPhp vendor/bin/phpunit --do-not-cache-result +``` + +Depois de terminar PHPUnit, executar `npm.cmd run build` na sessão normal. Não substituir o manifesto Vite enquanto testes Inertia o lêem. O PHP padrão do PATH não é adequado ao baseline PostgreSQL; não executar `composer setup`, que altera configuração e base. + +## Ficheiros e reversão + +Criados: `tools/zones-baseline.php`, `docs/investmoz/zones/README.md`, `diagnosis-and-adr.md`, `plan-and-traceability.md`, `baseline.md`, `baseline-data.json`. Nenhum ficheiro aplicacional removido. Build regenera artefactos derivados em `public/build` e ficheiros Wayfinder. Endpoints, componentes de produto e dados de negócio permanecem funcionalmente iguais; nenhuma migration criada nesta fase. Rollback da fase consiste em retirar os novos artefactos de documentação/inventário, preservando os backups; não exige rollback de base. + +Não havia `.git`. Foi inicializado repositório local na branch `feature/zee-zfi-catalogue`, usando `C:/laragon/bin/git/cmd/git.exe`. Não há identidade Git configurada; commits não executados e nenhum ficheiro sensível adicionado ao índice. Antes de versionar a aplicação inteira, rever exclusões de anexos, dumps e storage privado. Não confundir branch sem commit inicial com histórico de recuperação completo. + +## Limites e riscos + +- Testes passam para o código actual; não comprovam requisitos ZEE/ZFI ainda não implementados. +- Nenhum novo ensaio de navegador, carga, leitor de ecrã, falha de rede ou UAT foi executado nesta fase. Evidências anteriores estão em `../unification/validation.md` e não são homologação do novo catálogo. +- Ausência de geometrias impede demonstrar cobertura territorial real. Não inventar coordenadas ou perímetros. +- Fontes jurídicas, entidades competentes, validade e nomes das zonas exigem conferência documental na Fase 1. Não foram carregadas regras fiscais ou certificados. +- R2–R5 da revisão arquitectural continuam abertos; a Fase 0 não os resolve. +- A aprovação editorial anterior pelo próprio gestor conflita com o novo requisito de segregação. Novo domínio seguirá revisão independente; a transição do legado precisa decisão explícita e migração de permissões. +- SLO/SLA, retenção, responsáveis nominais e calendário de UAT não foram fornecidos. Devem ser decisões registadas, não valores inventados. + +## Checklist de gate + +| Condição | Estado | +| --- | --- | +| Código acessível e versões/comandos identificados | Sim | +| Backup existente com restauro documentado e checksum conferido | Sim | +| Baseline de testes reproduzido | Sim | +| Diagnóstico, matriz de decisões, ADR e backlog preparados | Sim | +| Escopo piloto aprovado pelo proprietário | Pendente — proposta no README | +| Identidade Git e primeiro commit | Pendente | +| Transição de permissões e responsáveis de validação | Pendente | +| Autorização para iniciar Fase 1 | A aguardar decisão no gate | + +Recomendação: **Go condicionado**. Próxima fase proposta: ER/dicionário, matriz jurídica, identidade canónica e relações tipadas, migrations aditivas e testes GIS/integridade/reversão em base isolada. Não iniciada. diff --git a/docs/investmoz/zones/browser-validation.json b/docs/investmoz/zones/browser-validation.json new file mode 100644 index 00000000..5917f0f2 --- /dev/null +++ b/docs/investmoz/zones/browser-validation.json @@ -0,0 +1,101 @@ +{ + "date": "2026-09-28T06:31:57.045Z", + "scope": "Public pages and empty states only; no institutional workflow/UAT claim", + "observations": [ + { + "path": "/zonas?category=zee", + "width": 390, + "title": "ZEE, ZFI e parques industriais", + "overflow": false, + "breadcrumb": true, + "support": true, + "dimensions": true + }, + { + "path": "/zonas?category=zfi", + "width": 390, + "title": "ZEE, ZFI e parques industriais", + "overflow": false, + "breadcrumb": true, + "support": true, + "dimensions": true + }, + { + "path": "/zonas?category=industrial_park", + "width": 390, + "title": "ZEE, ZFI e parques industriais", + "overflow": false, + "breadcrumb": true, + "support": true, + "dimensions": true + }, + { + "path": "/oportunidades", + "width": 390, + "title": "Catálogo de investimento", + "overflow": false, + "breadcrumb": true, + "support": true, + "dimensions": true + }, + { + "path": "/oportunidades?view=combined", + "width": 390, + "title": "Catálogo de investimento", + "overflow": false, + "breadcrumb": true, + "support": true, + "dimensions": true + }, + { + "path": "/zonas?category=zee", + "width": 1440, + "title": "ZEE, ZFI e parques industriais", + "overflow": false, + "breadcrumb": true, + "support": true, + "dimensions": true + }, + { + "path": "/zonas?category=zfi", + "width": 1440, + "title": "ZEE, ZFI e parques industriais", + "overflow": false, + "breadcrumb": true, + "support": true, + "dimensions": true + }, + { + "path": "/zonas?category=industrial_park", + "width": 1440, + "title": "ZEE, ZFI e parques industriais", + "overflow": false, + "breadcrumb": true, + "support": true, + "dimensions": true + }, + { + "path": "/oportunidades", + "width": 1440, + "title": "Catálogo de investimento", + "overflow": false, + "breadcrumb": true, + "support": true, + "dimensions": true + }, + { + "path": "/oportunidades?view=combined", + "width": 1440, + "title": "Catálogo de investimento", + "overflow": false, + "breadcrumb": true, + "support": true, + "dimensions": true + }, + { + "scenario": "map_request_blocked", + "fallback_list": true + } + ], + "errors": [] +} \ No newline at end of file diff --git a/docs/investmoz/zones/current-data.json b/docs/investmoz/zones/current-data.json new file mode 100644 index 00000000..ca7676b0 --- /dev/null +++ b/docs/investmoz/zones/current-data.json @@ -0,0 +1,123 @@ +{ + "generated_at": "2026-09-28T06:24:38+00:00", + "php": "8.5.11", + "postgres": "17.11", + "postgis": "3.6.2", + "table_counts": { + "activities": 0, + "aftercare_documents": 0, + "aftercare_plans": 0, + "ai_chunks": 0, + "ai_controls": 7, + "ai_feedback": 0, + "ai_runs": 0, + "ai_sources": 0, + "anchor_project_links": 0, + "audit_events": 29, + "cache": 10, + "cache_locks": 0, + "cluster_projects": 13, + "complementary_project_links": 0, + "consents": 0, + "constraints": 0, + "contacts": 0, + "corridor_clusters": 20, + "corridor_locations": 0, + "corridor_projects": 17, + "courses": 0, + "crm_opportunities": 0, + "development_corridors": 6, + "documents": 0, + "economic_clusters": 8, + "education_documents": 0, + "education_investment_proposals": 0, + "education_opportunities": 0, + "escalations": 0, + "failed_jobs": 0, + "geo_geometries": 0, + "geo_locations": 0, + "inbox_events": 0, + "interest_requests": 0, + "interest_submission_receipts": 0, + "investment_accounts": 0, + "investment_company_declarations": 0, + "investment_dossiers": 29, + "investment_gaps": 0, + "investment_item_opportunities": 6, + "investment_item_projects": 0, + "investment_items": 6, + "investment_legal_sources": 0, + "investment_messages": 0, + "investment_notifications": 0, + "investment_reference_options": 114, + "investment_specific_needs": 0, + "investment_submission_attachments": 0, + "investment_submission_events": 0, + "investment_submission_revisions": 0, + "investment_submission_zones": 0, + "investment_submissions": 0, + "investment_zone_geometries": 0, + "investment_zone_links": 0, + "investment_zone_relations": 0, + "investment_zone_revisions": 0, + "investment_zones": 0, + "investor_accounts": 0, + "investor_contacts": 0, + "investor_documents": 0, + "investor_journeys": 0, + "investors": 1, + "job_batches": 0, + "jobs": 0, + "journey_documents": 0, + "journey_steps": 0, + "labour_demands": 0, + "leads": 0, + "memberships": 0, + "migrations": 38, + "occupations": 0, + "opportunities": 6, + "opportunity_clusters": 0, + "opportunity_corridors": 0, + "opportunity_facets": 0, + "opportunity_favourites": 0, + "opportunity_locations": 0, + "opportunity_profiles": 0, + "opportunity_projects": 0, + "organizations": 0, + "outbox_events": 0, + "passkeys": 0, + "password_reset_tokens": 0, + "portal_partnerships": 0, + "portal_resources": 0, + "project_documents": 0, + "project_locations": 0, + "projects": 16, + "reconciliation_issues": 0, + "sectors": 6, + "sessions": 3, + "skills": 0, + "spatial_ref_sys": 8500, + "submissions": 0, + "sync_runs": 0, + "tasks": 0, + "training_capacities": 0, + "training_needs": 0, + "training_providers": 0, + "users": 1, + "utilizadores": 0, + "value_chain_links": 0, + "value_chains": 0 + }, + "checks": { + "invalid_geometries": 0, + "duplicate_opportunity_references": 0, + "opportunities_without_promoter": 0, + "opportunities_without_profile_source": 6, + "projects_without_description": 1 + }, + "limits": [ + "No semantic deduplication or legal verification performed.", + "Empty geometry inventory does not demonstrate official perimeter coverage.", + "Document orphan and project promoter checks require explicit ownership mapping in phase 1." + ] +} diff --git a/docs/investmoz/zones/data-dictionary.md b/docs/investmoz/zones/data-dictionary.md new file mode 100644 index 00000000..cda3315d --- /dev/null +++ b/docs/investmoz/zones/data-dictionary.md @@ -0,0 +1,39 @@ +# Dicionário do núcleo implementado + +| Tabela | Identidade e relações | Regras | +| --- | --- | --- | +| investment_items | UUID; código único; tipo; título; versão | Representação canónica interna; não duplica a oportunidade pública | +| investment_item_opportunities | item UUID → opportunity bigint único | Correspondência explícita; conserva ID, slug e URLs existentes | +| investment_item_projects | item UUID → project UUID único | Correspondência só com evidência; não inferida por nome | +| investment_zones | UUID; código único; operador FK organizations; fonte FK | Categoria zee/zfi/industrial_park/pending; publicação independente, editor/revisor, conteúdo PT/EN, versão; soft-delete | +| investment_legal_sources | UUID; proprietário e validador FK users | Diploma/artigo/URL/entidade/vigência; pending/validated/revoked; outra pessoa valida; alteração documental requer nova referência | +| investment_zone_links | item ↔ zona, par único | Papel, associação, prioridade, principal/secundária, evidência, período, fonte jurídica opcional, editor/validador e versão | +| investment_zone_relations | zona ↔ exactamente um cluster/corredor/projecto | FKs explícitas e índice único por dimensão; múltiplas zonas por âncora; mesmas regras temporais e de validação | +| investment_zone_geometries | zona ↔ geo_geometries | Finalidade, fonte, entidade, data, permissão de publicação, editor/revisor GIS; estatuto não inferido por localização | +| investment_zone_revisions | zona/versão únicos | Snapshot encriptado, SHA-256, actor/acção/data; trigger recusa UPDATE/DELETE | +| investment_submission_zones | submission UUID → zona UUID | Versão consultada e finalidade; origem preservada no protocolo, sem listas relacionais em JSON | + +Campos jurídicos e coordenadas não têm valores reais predefinidos. As migrations não semeiam estatutos, percentagens ou entidades. As restrições de geometria existentes asseguram 2D, SRID 4326, não vazio, tipo correcto, validade topológica e limites terrestres. Todas as novas FKs usam restrição de eliminação. + +## Relações ER + +```mermaid +erDiagram + investment_items ||--o{ investment_item_opportunities : maps + opportunities ||--o| investment_item_opportunities : preserves_id + investment_items ||--o{ investment_item_projects : maps + projects ||--o| investment_item_projects : preserves_id + investment_items ||--o{ investment_zone_links : associates + investment_zones ||--o{ investment_zone_links : contains_relation + investment_zones ||--o{ investment_zone_relations : territorial + projects |o--o{ investment_zone_relations : anchor_or_complement + economic_clusters |o--o{ investment_zone_relations : cluster + development_corridors |o--o{ investment_zone_relations : corridor + investment_legal_sources |o--o{ investment_zones : legal_reference + organizations |o--o{ investment_zones : operator + investment_zones ||--o{ investment_zone_geometries : locates + geo_geometries ||--o{ investment_zone_geometries : shape + investment_zones ||--o{ investment_zone_revisions : snapshots +``` + +Na relação territorial exactamente uma das três FKs de destino está preenchida. Operador não equivale a empresa instalada; cadastro empresarial e documentos existentes continuam separados. Revisão da zona não valida automaticamente as suas associações ou perímetros. diff --git a/docs/investmoz/zones/diagnosis-and-adr.md b/docs/investmoz/zones/diagnosis-and-adr.md new file mode 100644 index 00000000..1459086e --- /dev/null +++ b/docs/investmoz/zones/diagnosis-and-adr.md @@ -0,0 +1,47 @@ +# Diagnóstico e ADR-001 proposto + +## Inventário confirmado + +| Área | Implementação e convenções | Lacuna para o pedido | +| --- | --- | --- | +| Backend | Laravel 13.30.1; PHP de validação 8.5.11; Inertia Laravel 3.3.3; Fortify 1.39.0 | PHP do PATH não é o executável validado com PostgreSQL | +| Frontend | React 19.2.8; Inertia React 3.7.0; TypeScript 5.9.3; Vite 8.2.2; Node 24.20.0 | PT/EN integral, mapa territorial, lista–mapa, testes assistivos e estados de falha incompletos | +| Dados | PostgreSQL 17.11/PostGIS 3.6.2; 93 tabelas; 35 migrations | Sem mestre ZEE/ZFI/parques ou regras jurídicas versionadas | +| Catálogo | `Opportunity` bigint, slug/reference, filtros em `InvestmentCatalogue`, contexto M:N em `InvestmentContextController` | Não representa ainda todos os tipos canónicos; pivots precisam fonte, vigência, papel e validação | +| Território | UUID em `projects`, `economic_clusters`, `development_corridors`; dossiers públicos separados | Não confundir dossier editorial com investimento; correspondência projecto–oportunidade por reconciliar | +| GIS | `geo_locations`, `geo_geometries`, SRID 4326, checks de tipo/validade/vazio/coordenadas | Zero geometrias locais; falta proveniência oficial, perímetro versionado e pesquisa espacial completa | +| CRM | `CrmIntake`, conversão para lead, outbox/inbox; `app/Integrations/SuiteCrm/SuiteCrmClient.php` e testes com simulação | Integração real não homologada; não executar chamadas externas nesta fase | +| Autenticação | Sessão, email verificado, gate `manage-portal`, suporte Fortify/passkeys | Gate por lista de gestores não substitui RBAC por função/atributo nem MFA obrigatório | +| Privacidade | Payloads e revisões de manifestações encriptados; ficheiros privados; middleware no-store | Retenção, segregação de funções e revisão editorial territorial integral pendentes | +| IA | Pesquisa lexical/rules interna com fontes e suspensão | Sem geração/RAG; achados R2–R5 da revisão continuam abertos | + +Árvore funcional: `app/Models`, `app/Services`, `app/Http/Controllers`, `app/Integrations`, `database/migrations`, `database/seeders`, `resources/js/pages/{investment,portfolio,management}`, `routes/{web,investment}.php`, `tests/{Feature,Unit}`, `deploy/{linux,suitecrm}`, `docs/investmoz`. + +Testes relevantes: `InvestmentStructureTest`, `InvestmentPortfolioTest`, `InvestmentUnificationTest`, `InvestmentWorkspaceTest`, `ClusterValueChainTest`, `ManagementAccessTest`, `CrmIntakeTest`, `SuiteCrm/*`, `PublicPortalNavigationTest` e `PortalServicesTest`. + +Inventário local: seis oportunidades; 16 projectos (não confundir com as 15 âncoras documentais); oito clusters; seis corredores; 29 dossiers; 114 opções; zero geometrias, documentos e relações opportunity–cluster/corridor/project. Sem referências de oportunidade duplicadas; seis oportunidades sem `opportunity_profiles.source_reference`; um projecto sem descrição. Não se conclui que todo o restante conteúdo esteja correcto: deduplicação semântica, promotores de projectos e proveniência jurídica exigem reconciliação própria. + +## Conservar–Melhorar–Substituir–Descontinuar + +| Decisão | Objecto | Tratamento | +| --- | --- | --- | +| Conservar | IDs, slugs, dossiers, pedidos, consentimentos, anexos, favoritos e API v1 | Adaptadores e testes de contrato; sem apagar ou reclassificar em massa | +| Melhorar | Relações, cadastro GIS, fontes, auditoria, procura e páginas | FKs explícitas, temporalidade, validação, índices e permissões | +| Substituir gradualmente | Gate único de gestor para actos críticos | Políticas separadas e revisão independente; sem conceder privilégios por registo público | +| Substituir gradualmente | Mapa de pontos como única vista geográfica | Camadas autorizadas e lista sempre utilizável quando mapa falhar | +| Descontinuar após migração | Escritas paralelas da mesma identidade | Só após correspondência aprovada e reconciliação; nenhuma removida agora | +| Não importar | Estatutos, perímetros e benefícios apenas citados no prompt | Manter pendência documental, sem inferir regime jurídico | + +## Decisão arquitectural proposta + +Adoptar um registo canónico UUID `investment_items`, com tabelas de correspondência tipadas para `opportunities` e `projects`, conservando os IDs legados. Um investimento pode ter representação em ambos sem duplicação. Não equiparar automaticamente registos por título nem criar uma nova oportunidade para cada dossier. Itens sem correspondência comprovada ficam em fila de reconciliação; rotas existentes usam adaptadores. A escolha final e o ER detalhado pertencem à Fase 1. + +Zonas partilham cadastro físico com categoria explícita ZEE/ZFI/parque, mas estatuto jurídico, operador, empresa, título, certificado e benefício são entidades distintas. Um parque não herda regime por localização. FKs tipadas nas relações; nunca arrays de IDs em JSON para associações pesquisáveis. JSON limita-se a atributos não relacionais e snapshots de revisão. + +Relações têm papel, principal/secundária, tipo de associação, fonte, justificação, prioridade, período, estado, validador e revisão imutável. Ligações jurídicas validadas ficam distintas de influência, proximidade calculada e potencial. Unicidade por par/tipo/período; prevenção de ciclos para hierarquia e dependências, sem impor exclusividade entre dimensões. + +Preservar SRID 4326 do modelo existente. GIS calcula distância/intersecção/inclusão sem alterar estatuto. Publicação de perímetro exige fonte autorizada, validador, vigência, versão e permissão de divulgação. Simplificação para apresentação não substitui a geometria de referência. + +Lei 8/2023 e Decreto 8/2024 são diplomas indicados pelo requerente, ainda não conferidos juridicamente nesta fase. Matriz futura deve guardar diploma/artigo/sujeito/condição/competência/vigência/versão/fonte/validação/histórico. Não se afirma conformidade legal integral com base no inventário técnico. + +Alternativas rejeitadas: catálogo independente por zona (duplica investimentos); converter geometrias em estatuto (infere direito); substituir IDs existentes (quebra contratos); publicar nomes indicados como cadastro oficial sem prova. diff --git a/docs/investmoz/zones/implementation-status.md b/docs/investmoz/zones/implementation-status.md new file mode 100644 index 00000000..aad396ac --- /dev/null +++ b/docs/investmoz/zones/implementation-status.md @@ -0,0 +1,40 @@ +# Execução autorizada — 28/09/2026 + +O proprietário autorizou progressão automática entre fases e reiterou «prossiga». Não há nova aprovação de fase a pedir. As verificações técnicas e as pendências de dados mantêm-se registadas. + +## Implementado até ao momento + +- Migration `2026_09_28_100000_investment_zone_foundation`: nove tabelas aditivas; entidades ZEE/ZFI/parque separadas do enquadramento jurídico; identidade UUID com correspondências tipadas de oportunidades/projectos; associações M:N; revisão editorial encriptada e imutável. +- Migration `2026_09_28_110000_zone_geometry_review`: editor e revisor GIS separados. Geometrias PostGIS existentes reutilizadas; upload GeoJSON validado, perímetro exige polígono e referência legal, sobreposição de perímetros é remetida para revisão. +- APIs v2 para cadastro, fontes, validação/revogação, ligações, relações territoriais, geometrias e mapa. API v1 preservada, com filtros opcionais `zee_id`, `zfi_id`, `park_id`; resultados continuam a usar IDs canónicos de oportunidades existentes. +- As quatro entradas filtram o mesmo catálogo através de `dimension=cluster|corridor|zee|zfi`; filtros compatíveis são mantidos. `view=grid|list|map|combined` conserva a vista no URL. Falha do mapa apresenta a lista e mensagem de recuperação; ensaio de navegador confirmado. +- Páginas `/zonas`, `/zonas/{id}`, `/management/investment/zones` e `/management/investment/zones/{id}`. Catálogo tem quatro entradas; ficha de oportunidade e carteira territorial exibem associações publicadas. +- Edição PT/EN, formulário de fontes, relações e GIS; revisões e conflitos de versão. Fonte revogada ou vencida oculta zonas/associações e geometrias da consulta pública sem eliminar registos. +- Gestão exige autenticação, email verificado, gate de gestor e MFA confirmado. Validar fonte/GIS e publicar exigem designação em configuração. Auto-aprovação recusada. Suspensão/revogação continua acessível a gestores com MFA para retirar informação rapidamente. +- Migration `2026_09_28_120000_investment_submission_zones`: origem normalizada por FK, versão da zona e finalidade interest/dossier/meeting. Rascunho deduplicado por utilizador/zona/versão/investimento/finalidade; submissão revalida disponibilidade e versão, conserva origem no CRM e snapshot privado, e reutiliza protocolo nos reenvios. +- Comando `investment:reconcile-identities`: dry-run por omissão; `--apply` cria apenas correspondência directa de IDs. Executado localmente para seis oportunidades, preservando registos e URLs; 16 projectos continuam por reconciliar documentalmente, sem fusão automática. + +## Aplicação local e reversão + +Primeiro backup: `storage/app/private/zones-backup-20260928-015930/`, restaurado e conferido em 93 tabelas. Primeira migration levou a 102 tabelas; apenas migrations 35→36, novas tabelas vazias. + +Segundo backup: `storage/app/private/zones-backup-20260928-021135/`, restaurado e conferido em 102 tabelas. Segunda migration manteve 102 tabelas; migrations 36→37. Sessões 3→2 durante uso local; contagens de negócio preservadas. Manifestações, oportunidades e dossiers não foram convertidos ou publicados em massa. Nenhuma zona fictícia foi inserida na base operacional. + +Rollback das novas tabelas recusa se existirem dados; rollback vazio foi testado isoladamente. Com dados, usar restauro isolado do backup, reconciliar com a aplicação compatível e preservar a base actual para recuperação. Não executar rollback global das migrations antigas. + +Terceiro backup: `zones-backup-20260928-061527/`, 102 tabelas restauradas/conferidas antes da origem de manifestações. Após migration: 103 tabelas, migrations 37→38, tabela nova vazia. Quarto backup: `zones-backup-20260928-062127/`, 103 tabelas verificadas antes da correspondência canónica. Reconciliação posterior: apenas `investment_items` e `investment_item_opportunities` mudaram de zero para seis; restantes contagens iguais. Relatórios privados em `storage/app/private/zone-reconciliation/`. [Inventário actual agregado](current-data.json). + +## Limites actuais por fase + +| Fase | Estado factual | Trabalho ainda necessário | +| --- | --- | --- | +| 0 | Baseline e plano executados | Identidade Git para commits; não configurada em nome do utilizador | +| 1 | Núcleo canónico/legal/GIS aditivo implementado | Modelo completo de actividades, benefícios, obrigações, instalações, lotes e indicadores; matriz jurídica por artigo; correspondências projecto–oportunidade reconciliadas | +| 2 | APIs e administração do núcleo implementadas | Workflows institucionais completos, histórico de todas as entidades, atribuição nominal de funções, MFA de sessão e testes de contrato exaustivos | +| 3 | Filtros das zonas integrados com resultados sem duplicação | Descoberta indirecta por elos/nós/influência, ranking/sinónimos, telemetria e benchmark representativo | +| 4 | Cadastro e fichas com conteúdo PT/EN, mapa esquemático, lista–mapa, filtros/vista no URL e recuperação da falha do mapa | Interface integral EN, acessibilidade assistiva e E2E de todas as jornadas | +| 5 | Interesse/dossier/reunião assistidos com origem zona/versão/finalidade no protocolo e CRM interno | Origem completa de filtros, SLA parametrizado e integração externa real formalmente autorizada | +| 6 | Backups/restauros, dry-run, seis correspondências canónicas e reconciliação executados | UAT, carga, formação real, recuperação pontual e piloto com dados oficiais | +| 7 | Não iniciada | Decisão de produção, suporte e requisitos operacionais | + +Não declarar a implementação integral concluída com base nesta entrega. Zero geometrias e zonas oficiais estão cadastradas localmente; o estado vazio é intencional até haver documentos e responsáveis. Configurar `INVESTMOZ_ZONE_LEGAL_REVIEWERS`, `INVESTMOZ_ZONE_GIS_REVIEWERS`, `INVESTMOZ_ZONE_PUBLISHERS` com emails de gestores já autorizados, após designação. Estas listas não atribuem competência legal por si próprias. diff --git a/docs/investmoz/zones/legal-matrix.md b/docs/investmoz/zones/legal-matrix.md new file mode 100644 index 00000000..a8b7e316 --- /dev/null +++ b/docs/investmoz/zones/legal-matrix.md @@ -0,0 +1,13 @@ +# Evidência jurídica — levantamento inicial + +Consulta em 28/09/2026. As referências abaixo são pontos de partida para conferência por quem detém competência jurídica; não são regras operacionais validadas nem autorizam publicação de taxas, benefícios ou certificados. + +| Referência | Fonte encontrada | Estado / tratamento | +| --- | --- | --- | +| Lei 8/2023 e Decreto 8/2024 indicados no pedido | [Legislação APIEX](https://pt.apiex.gov.mz/legislacao/) e [compilação de legislação de investimentos](https://pt.apiex.gov.mz/wp-content/uploads/2025/02/LEGISLACAO-DE-INVESTIMENTOS-EM-MOCAMBIQUE.pdf) | Conferir texto, artigo, alterações posteriores, vigência e sujeito antes de registar regra validada | +| Cadastro e regime ZEE | [Página institucional de ZEE](https://apiex.gov.mz/zona-economica-especial/) | Informação de orientação; obter diploma e perímetro por zona; não converter percentagens da página em parâmetros do simulador | +| Regime ZFI | [Página institucional ZFI](https://apiex.gov.mz/ifz/industrial-free-zone/) | Não confirma título individual de Beluluane, Locone ou Minhuene | + +Nenhuma destas fontes foi importada como validada. O sistema exige referência HTTPS, diploma, artigo, entidade, início/fim de vigência, proprietário e revisão por pessoa distinta. A verificação de formulário não certifica autenticidade nem competência legal. Substituições devem criar nova referência; revogação retira imediatamente as zonas e associações dependentes da consulta pública. + +Pendente: diplomas específicos de criação, operadores/títulos, plantas autorizadas, regimes fiscais/aduaneiros em vigor, actividades condicionadas, entidades actualmente competentes, licenças e condições por sujeito. Manter dados sem prova em rascunho e não produzir cálculos legais. diff --git a/docs/investmoz/zones/openapi.json b/docs/investmoz/zones/openapi.json new file mode 100644 index 00000000..8d973292 --- /dev/null +++ b/docs/investmoz/zones/openapi.json @@ -0,0 +1,1460 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "InvestMoz zone foundation API", + "version": "2026-09-28", + "description": "Implemented local routes only. Session cookies and CSRF; no bearer protocol invented. Admin: verified manager with confirmed MFA; legal/GIS/publish actions also require configured designated role. 30 requests/minute for management; public API uses application throttle. Approval in the portal is documentary review, not an administrative act. API v1 remains compatible and adds optional zee_id/zfi_id/park_id filters. Response schemas are deliberately open pending full contract typing." + }, + "paths": { + "/api/v2/investment/admin/legal-sources": { + "get": { + "operationId": "InvestmentLegalSourceController_index_0", + "summary": "InvestmentLegalSourceController@index", + "parameters": [], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "200": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + } + }, + "post": { + "operationId": "InvestmentLegalSourceController_store_1", + "summary": "InvestmentLegalSourceController@store", + "parameters": [], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "201": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LegalSource" + } + } + } + } + } + }, + "/api/v2/investment/admin/legal-sources/{id}/{action}": { + "post": { + "operationId": "InvestmentLegalSourceController_transition_2", + "summary": "InvestmentLegalSourceController@transition", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "action", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "validate", + "revoke" + ] + } + } + ], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "200": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + } + } + } + } + } + }, + "/api/v2/investment/admin/zone-geometries/{id}/{action}": { + "post": { + "operationId": "InvestmentZoneGeometryController_review_3", + "summary": "InvestmentZoneGeometryController@review", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "action", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "validate", + "revoke" + ] + } + } + ], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "200": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + } + } + } + } + } + }, + "/api/v2/investment/admin/zone-links/{id}/{action}": { + "post": { + "operationId": "InvestmentZoneLinkController_transition_4", + "summary": "InvestmentZoneLinkController@transition", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "action", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "validate", + "revoke" + ] + } + } + ], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "200": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + } + } + } + } + } + }, + "/api/v2/investment/admin/zone-relations/{id}/{action}": { + "post": { + "operationId": "InvestmentZoneRelationController_review_5", + "summary": "InvestmentZoneRelationController@review", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "action", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "validate", + "revoke" + ] + } + } + ], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "200": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + } + } + } + } + } + }, + "/api/v2/investment/admin/zones": { + "get": { + "operationId": "InvestmentZoneController_index_6", + "summary": "InvestmentZoneController@index", + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string", + "maxLength": 120 + } + }, + { + "name": "category", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "zee", + "zfi", + "industrial_park" + ] + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "200": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + } + }, + "post": { + "operationId": "InvestmentZoneController_save_7", + "summary": "InvestmentZoneController@save", + "parameters": [], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "201": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Zone" + } + } + } + } + } + }, + "/api/v2/investment/admin/zones/{id}": { + "get": { + "operationId": "InvestmentZoneController_show_8", + "summary": "InvestmentZoneController@show", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "200": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + } + }, + "put": { + "operationId": "InvestmentZoneController_save_9", + "summary": "InvestmentZoneController@save", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "200": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Zone" + } + } + } + } + } + }, + "/api/v2/investment/admin/zones/{id}/geometries": { + "post": { + "operationId": "InvestmentZoneGeometryController_store_10", + "summary": "InvestmentZoneGeometryController@store", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "201": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Geometry" + } + } + } + } + } + }, + "/api/v2/investment/admin/zones/{id}/links": { + "post": { + "operationId": "InvestmentZoneLinkController_store_11", + "summary": "InvestmentZoneLinkController@store", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "201": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Link" + } + } + } + } + } + }, + "/api/v2/investment/admin/zones/{id}/relations": { + "post": { + "operationId": "InvestmentZoneRelationController_store_12", + "summary": "InvestmentZoneRelationController@store", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "201": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Relation" + } + } + } + } + } + }, + "/api/v2/investment/admin/zones/{id}/revisions": { + "get": { + "operationId": "InvestmentZoneController_revisions_13", + "summary": "InvestmentZoneController@revisions", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "200": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v2/investment/admin/zones/{id}/{action}": { + "post": { + "operationId": "InvestmentZoneController_transition_14", + "summary": "InvestmentZoneController@transition", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "action", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "publish", + "suspend" + ] + } + } + ], + "security": [ + { + "laravelSession": [] + } + ], + "responses": { + "200": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + } + } + } + } + } + }, + "/api/v2/investment/zone-map": { + "get": { + "operationId": "InvestmentZoneGeometryController_map_15", + "summary": "InvestmentZoneGeometryController@map", + "parameters": [ + { + "name": "zone_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "bbox[]", + "in": "query", + "style": "form", + "explode": true, + "schema": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "number" + } + }, + "description": "west,south,east,north; no antimeridian wrap." + } + ], + "security": [], + "responses": { + "200": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v2/investment/zones": { + "get": { + "operationId": "InvestmentZoneController_index_16", + "summary": "InvestmentZoneController@index", + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string", + "maxLength": 120 + } + }, + { + "name": "category", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "zee", + "zfi", + "industrial_park" + ] + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "security": [], + "responses": { + "200": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + }, + "/api/v2/investment/zones/{id}": { + "get": { + "operationId": "InvestmentZoneController_show_17", + "summary": "InvestmentZoneController@show", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [], + "responses": { + "200": { + "description": "Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "403": { + "description": "Missing manager/MFA/designated role or self-review." + }, + "404": { + "description": "Not found or not publicly visible." + }, + "409": { + "description": "Stale version or duplicate relation." + }, + "419": { + "description": "Session/CSRF validation failed." + }, + "422": { + "description": "Invalid data, source, geometry, overlap or workflow." + }, + "429": { + "description": "Rate limit exceeded." + } + } + } + } + }, + "components": { + "securitySchemes": { + "laravelSession": { + "type": "apiKey", + "in": "cookie", + "name": "laravel_session", + "description": "Actual session cookie name is configured by Laravel. Send X-XSRF-TOKEN from the session; no credentials embedded." + } + }, + "schemas": { + "Version": { + "type": "object", + "properties": { + "version_no": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "version_no" + ] + }, + "Zone": { + "type": "object", + "properties": { + "version_no": { + "type": "integer", + "minimum": 1 + }, + "code": { + "type": "string", + "maxLength": 80 + }, + "category": { + "type": "string", + "enum": [ + "zee", + "zfi", + "industrial_park", + "pending" + ] + }, + "name": { + "type": "string", + "maxLength": 255 + }, + "name_en": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "description_en": { + "type": "string", + "nullable": true + }, + "province": { + "type": "string", + "nullable": true + }, + "district": { + "type": "string", + "nullable": true + }, + "operator_id": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "legal_source_id": { + "type": "string", + "format": "uuid", + "nullable": true + } + }, + "required": [ + "code", + "category", + "name" + ], + "description": "PUT requires version_no. Save withdraws publication and requires a new independent review. No official status inferred from category." + }, + "LegalSource": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri", + "description": "HTTPS reference only; no remote download." + }, + "diploma": { + "type": "string" + }, + "article": { + "type": "string" + }, + "competent_entity": { + "type": "string" + }, + "valid_from": { + "type": "string", + "format": "date" + }, + "valid_until": { + "type": "string", + "format": "date", + "nullable": true + } + }, + "required": [ + "title", + "url", + "diploma", + "article", + "competent_entity", + "valid_from" + ] + }, + "Link": { + "type": "object", + "properties": { + "opportunity_id": { + "type": "integer", + "minimum": 1 + }, + "association_type": { + "type": "string", + "enum": [ + "official", + "influence", + "proximity", + "potential", + "historical" + ] + }, + "role": { + "type": "string", + "enum": [ + "primary", + "beneficiary", + "served", + "enabled", + "dependent", + "related", + "potential", + "historical" + ] + }, + "source_reference": { + "type": "string", + "minLength": 10, + "maxLength": 2000 + }, + "justification": { + "type": "string", + "minLength": 10, + "maxLength": 3000 + }, + "legal_source_id": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "valid_from": { + "type": "string", + "format": "date" + }, + "valid_until": { + "type": "string", + "format": "date", + "nullable": true + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "is_primary": { + "type": "boolean" + } + }, + "required": [ + "opportunity_id", + "association_type", + "role", + "source_reference", + "justification", + "valid_from" + ], + "description": "Official association requires legal_source_id. Creates or reuses UUID mapping for existing opportunity; duplicate zone-item pair returns 409. Potential remains potential after review." + }, + "Relation": { + "type": "object", + "properties": { + "cluster_id": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "corridor_id": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "project_id": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "association_type": { + "type": "string", + "enum": [ + "official", + "influence", + "proximity", + "potential", + "historical" + ] + }, + "role": { + "type": "string", + "enum": [ + "primary", + "beneficiary", + "served", + "enabled", + "dependent", + "related", + "potential", + "historical" + ] + }, + "source_reference": { + "type": "string", + "minLength": 10, + "maxLength": 2000 + }, + "justification": { + "type": "string", + "minLength": 10, + "maxLength": 3000 + }, + "legal_source_id": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "valid_from": { + "type": "string", + "format": "date" + }, + "valid_until": { + "type": "string", + "format": "date", + "nullable": true + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "is_primary": { + "type": "boolean" + } + }, + "required": [ + "association_type", + "role", + "source_reference", + "justification", + "valid_from" + ], + "description": "Exactly one of cluster_id, corridor_id, project_id. Official association requires legal_source_id. Unique zone-target pair; duplicate returns 409." + }, + "Geometry": { + "type": "object", + "properties": { + "geometry": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "Point", + "LineString", + "Polygon", + "MultiPolygon" + ] + }, + "coordinates": { + "type": "array", + "items": {} + } + }, + "required": [ + "type", + "coordinates" + ] + }, + "purpose": { + "type": "string", + "enum": [ + "official_perimeter", + "influence", + "site", + "alternative" + ] + }, + "legal_source_id": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "source_reference": { + "type": "string" + }, + "validating_entity": { + "type": "string" + }, + "observed_at": { + "type": "string", + "format": "date" + } + }, + "required": [ + "geometry", + "purpose", + "source_reference", + "validating_entity", + "observed_at" + ], + "description": "GeoJSON geometry in EPSG:4326, 2D, valid, nonempty, max 200000 encoded bytes. Official perimeter requires Polygon/MultiPolygon and legal source. No status inferred from geometry." + } + } + } +} diff --git a/docs/investmoz/zones/operations.md b/docs/investmoz/zones/operations.md new file mode 100644 index 00000000..573e8d82 --- /dev/null +++ b/docs/investmoz/zones/operations.md @@ -0,0 +1,38 @@ +# Administração e operação do núcleo local + +## Acesso e preparação + +1. Usar uma conta gestora existente, email verificado e dois factores confirmados nas definições da conta. +2. A administração designa revisores através de `INVESTMOZ_ZONE_LEGAL_REVIEWERS`, `INVESTMOZ_ZONE_GIS_REVIEWERS` e `INVESTMOZ_ZONE_PUBLISHERS` (emails separados por vírgula). Depois de alterar o ambiente, limpar/recriar a cache de configuração. Não incluir credenciais nos relatórios ou no Git. +3. Estas listas são controlo de acesso ao software, não uma atribuição de competência pública. O próprio editor não pode validar/publicar o seu registo, mesmo constando de todas as listas. +4. Entrar em `/management/investment/zones`. Um gestor sem MFA recebe 403 e deve configurar a conta antes de continuar. Acesso público em `/zonas`. + +## Cadastro e validação + +- Criar rascunho com código interno único, nome e categoria proposta. Usar pendente se o enquadramento não estiver comprovado. +- Abrir a ficha e completar PT/EN, território, operador já cadastrado e referência jurídica. Os selectores administrativos apresentam até 200 registos de destino e 100 fontes; para catálogos maiores falta pesquisa paginada específica. Não criar uma organização apenas para ultrapassar a validação. +- Registar fonte HTTPS, diploma, artigo, entidade indicada, início e fim de vigência. Não há download automático do URL. Outra pessoa designada para revisão jurídica confere e valida a referência. Alteração do texto/documento exige nova referência; revogar retira a fonte dos resultados dependentes. +- Guardar alterações gera nova versão em rascunho. Outra pessoa designada para publicação revê conteúdo e referência; publicar exige os campos previstos e fonte vigente. Esta publicação editorial não equivale a certificado/licença. +- Se a página estiver desactualizada, o servidor responde 409; recarregar e rever as diferenças antes de repetir. Suspender retira a ficha sem eliminar o histórico. + +## Associações e GIS + +- Seleccionar oportunidade, cluster, corredor ou projecto existente. Indicar papel, natureza da associação, fonte, justificação e vigência. Relações potenciais continuam potenciais depois da validação; a validação não as torna oficiais. +- Relação oficial exige referência jurídica própria. Uma âncora pode pertencer a várias zonas e parques; não há exclusividade implícita. Outro gestor revê a associação; par repetido responde 409. +- Submeter GeoJSON 2D no SRID 4326 com até 200 KB, fonte, finalidade e data. Perímetro oficial exige polígono/multipolígono e referência jurídica; não se deve desenhar fronteira estimada como oficial. +- Revisor GIS distinto valida e autoriza divulgação. Sobreposição de perímetros já autorizados bloqueia a operação e exige esclarecimento documental; não existe botão para declarar automaticamente regular a sobreposição. +- Mapa público só inclui geometrias autorizadas de zonas e fontes vigentes. É esquema sem mapa-base, não planta de demarcação. A API limita a 100 feições e aceita janela `bbox[]`; a ficha e o apoio mantêm-se disponíveis quando o mapa falha. + +## Atendimento e reconciliação + +«Manifestar interesse», «Solicitar dossier» e «Pedir reunião» abrem o percurso privado existente. São pedidos assistidos, não decisões administrativas, agendamentos confirmados ou distribuição automática de documentos. O investidor completa os dados e consentimento; recebe protocolo único. A origem zona/versão/finalidade segue no registo interno do pedido. A passagem por uma oportunidade mantém o contexto `zone_id` validado no servidor. + +Para identidade canónica, executar primeiro `php artisan investment:reconcile-identities` com o PHP validado. Rever o relatório privado e fazer backup/restauro. Só então `php artisan investment:reconcile-identities --apply`. O comando mapeia chaves existentes; não altera estado, fonte, URL ou título legado, não une projectos por semelhança e não publica registos. Reexecutar é idempotente. + +## Recuperação e monitorização + +Preservar a chave Laravel juntamente com os backups protegidos: snapshots encriptados dependem dela. Antes de alterações de esquema, exportar a base, restaurar isoladamente e conferir contagens/checksum. Reverter migrations vazias em ordem inversa; se houver dados, restaurar um backup numa base isolada e reconciliar, nunca apagar tabelas para resolver um erro. + +Monitorizar 403/409/422/429, erros 5xx, crescimento de `failed_jobs`, pendências de revisão e validade das fontes. Alertas automáticos de expiração, SLO/SLA de produção e formação com utilizadores reais continuam pendentes. Não activar SuiteCRM/Alfândegas externos sem acordo e ensaio próprios. + +Go local: núcleo testado e backups reconciliados. No-Go de produção: fontes reais, mandatos, segurança integral, carga, acessibilidade assistiva, UAT, suporte e recuperação operacional ainda não homologados. diff --git a/docs/investmoz/zones/plan-and-traceability.md b/docs/investmoz/zones/plan-and-traceability.md new file mode 100644 index 00000000..55c8d257 --- /dev/null +++ b/docs/investmoz/zones/plan-and-traceability.md @@ -0,0 +1,56 @@ +# Plano por ficheiro e rastreabilidade + +Tudo nesta página é proposto, excepto os artefactos de Fase 0. Caminhos e endpoints futuros não são anunciados como existentes. Responsáveis são papéis a designar, não aprovações já obtidas. + +Actualização: a matriz original abaixo conserva o plano. O [estado de implementação](implementation-status.md) e a tabela seguinte distinguem o código agora existente dos requisitos ainda pendentes. A autorização posterior permite progressão automática; os gates não exigem nova confirmação de fase. + +| Requisito coberto parcialmente | Migration / modelo | Serviço / API / ecrã existentes | Evidência automatizada | Responsabilidade | +| --- | --- | --- | --- | --- | +| RF-ZON-001/005/015 | `100000_investment_zone_foundation`, InvestmentZone | InvestmentZones; InvestmentZoneController; `/api/v2/investment/admin/zones`; `investment/zone.tsx` | InvestmentZoneTest: MFA, versão, revisão independente, snapshots encriptados e imutáveis | Implementação técnica executada; responsável institucional por designar | +| RF-ZON-003 | mesma migration; InvestmentLegalSource | InvestmentLegalSourceController; API legal-sources; formulário de fontes | Criação/revisão/revogação e ocultação imediata dos resultados dependentes | Jurídico por designar; nenhum benefício carregado | +| RF-ZON-002 | `110000_zone_geometry_review`; geo_geometries existente | InvestmentZoneGeometryController; zone-map; `zone-map.tsx` e `zone-relations.tsx` | Geometrias inválidas, revisão independente, fonte retirada, mapa sem divulgação indevida | GIS por designar | +| RF-ZON-006/012; RF-CLU/COR | identidade e M:N da migration 100000 | InvestmentIdentity/InvestmentCatalogue/InvestmentZones; v1 filtros opcionais e v2 zonas; catálogo e carteira | Oportunidade única em duas zonas; âncora em três zonas/parque; duplicados recusados | Dados/Produto por designar | +| RF-ZON-008 | `120000_investment_submission_zones` | InvestmentSubmissionController/InvestmentWorkspace; API v1 preservada; `investment/submission.tsx` | Rascunho deduplicado, protocolo idempotente, origem no atendimento, zona revogada recusada | Atendimento existente; homologação CRM externa pendente | +| Migração Fase 6 | correspondências tipadas | `investment:reconcile-identities` | Dry-run sem mutações; aplicação/repetição preserva UUID e dados legados | Execução local reconciliada | + +| Fase | Ficheiros / alterações previstas | Critério de saída | +| --- | --- | --- | +| 0 | `tools/zones-baseline.php`; `docs/investmoz/zones/*` | Inventário reproduzível, testes, diagnóstico, aprovação do piloto | +| 1 | Novas migrations aditivas `investment_identity`, `investment_zones`, `legal_evidence`, `typed_relations`, `geometry_provenance`; `docs/investmoz/zones/{erd,data-dictionary,legal-matrix,migration-plan}.md` | ER, FKs, unicidade, vigência, checks GIS, dry-run e rollback testados; aprovação Arquitectura/Dados/GIS/Jurídico | +| 2 | `app/Models/{InvestmentItem,InvestmentZone,LegalEvidence}.php`; services de revisão/publicação; policies; controllers/resources/requests; `routes/investment-zones.php`; OpenAPI | RBAC, MFA privilegiado, concorrência, histórico, autorização e contratos aprovados | +| 3 | Evoluir `InvestmentCatalogue`; repositório espacial; índices de pesquisa; telemetria agregada | Quatro dimensões, deduplicação por UUID, relações explicáveis, relevância e desempenho medidos | +| 4 | `resources/js/pages/investment/{catalogue,detail}.tsx`; páginas zonas; selector de dimensão, camadas/mapa e traduções | PT/EN, filtros URL, teclado, contraste, móvel, falhas parciais e E2E | +| 5 | `InvestmentSubmissionController`, intake, adaptador SuiteCRM, jobs/outbox, reconciliação | Interesse único com todas as origens, consentimento, retry e falha externa testados | +| 6 | Comandos dry-run/apply/reconcile; relatórios privados; runbook; fixtures sintéticas | Sem perda de IDs/URLs, restauro ensaiado, segurança/carga/UAT e formação | +| 7 | Deploy, observabilidade e plano operacional | Aprovação formal de produção e suporte | + +Modelo conceptual de relações, ainda sujeito ao ER da Fase 1: investimento → correspondências de oportunidade/projecto; investimento ↔ cluster/corredor/zona/parque/cadeia/infra-estrutura/território; zona → subzona/lote/instalação; zona ↔ operador/empresa/actividade; evidência jurídica → versões e validações; dimensão/relação/geometria → revisões e auditoria. PK/FK e cardinalidades exactas serão especificadas antes das migrations. + +## Matriz de rastreabilidade + +`Z1` = migration futura de zonas; `L1` = evidência jurídica; `R1` = relações tipadas; `G1` = proveniência GIS; `I1` = identidade canónica. `API zonas` e `API catálogo v2` são contratos a desenhar; API v1 é preservada. Cada teste abaixo é planeado, não executado. + +| Requisito | Entidade / migration | Modelo e serviço | API / ecrã | Teste de aceitação | Fase / responsável | +| --- | --- | --- | --- | --- | --- | +| RF-ZON-001 | Zona, revisão / Z1 | InvestmentZone, revisão | API zonas / cadastro | código único, concorrência, histórico | 1–2 / Dados | +| RF-ZON-002 | Geometria, lote / G1 | geometria e validação | mapa / perímetro | SRID, vazio, validade, conflito, acesso | 1–4 / GIS | +| RF-ZON-003 | Regra legal / L1 | regra e vigência | regimes / benefícios | expiração, contradição, não cumulação | 1–2 / Jurídico | +| RF-ZON-004 | Actividade/restrição / L1+R1 | elegibilidade assistida | actividades / condições | fundamento e excepção válidos | 1–2 / Jurídico | +| RF-ZON-005 | Ficha bilingue / Z1 | publicação | zona / detalhe | duas validações, fonte e tradução | 2–4 / Editorial | +| RF-ZON-006 | Item e dimensões / I1+R1 | pesquisa | catálogo / comparação | resultados únicos, filtros e acesso | 3–4 / Produto | +| RF-ZON-007 | Lote/instalação / Z1+G1 | ocupação | administração | reserva concorrente, contrato privado | 2–4 / Operação | +| RF-ZON-008 | Manifestação/origens / R1 | intake | submissions / formulário | protocolo único, consentimento | 5 / CRM | +| RF-ZON-009 | Processo e parecer / L1 | workflow assistido | processo / acompanhamento | sem acto oficial automático | 2–5 / Jurídico | +| RF-ZON-010 | Título/obrigação / L1 | validade e alertas | títulos / painel | vencimento retira informação vigente | 2 / Jurídico | +| RF-ZON-011 | Movimento/documento / Z1+L1 | operação assistida | gestão privada | não autoriza saída sem referência | 2–5 / Operação aduaneira | +| RF-ZON-012 | Relações / R1 | associações | dimensões / relações | M:N, fonte, período, sem duplicação | 1–4 / Dados | +| RF-ZON-013 | Infra-estrutura / R1 | capacidades | zona / infra-estruturas | capacidade, fonte, incidente | 2–4 / Operação | +| RF-ZON-014 | Indicador / Z1 | consolidação | indicadores / painel | separar realizado, autorizado, estimado | 2–4 / Dados | +| RF-ZON-015 | Papéis/revisões / L1 | policies e auditoria | administração | proibir auto-aprovação, isolamento | 1–6 / Segurança | +| RF-ZON-016 | Outbox/adaptador | integração | API versionada / operação | idempotência, retry, acordo formal | 5 / Integrações | +| RF-CLU-001* | Cluster/subcluster/cadeia / R1 | descoberta | catálogo / cluster | âncoras, elos, lacunas, fonte da relação | 1–4 / Produto e Dados | +| RF-COR-001* | Corredor/nó/influência / R1+G1 | descoberta espacial | catálogo / corredor | distinguir oficial, influência, proximidade | 1–4 / Produto e GIS | + +*IDs locais atribuídos aos requisitos textuais de Cluster/Corredor do documento, que não lhes atribui numeração explícita. + +Prioridade P0: identidade e compatibilidade, segregação, evidência jurídica, relações e GIS. P1: pesquisa quatro dimensões, PT/EN, interesse assistido, indicadores. P2: movimentos operacionais complexos, integrações institucionais e comparação avançada. A prioridade não elimina requisitos nem autoriza saltar gates. Os rótulos «MVP/Fase 2/Fase 3» da matriz funcional do anexo serão conciliados com as fases de execução 0–7 por capacidade, sem assumir equivalência numérica. diff --git a/docs/investmoz/zones/validation.md b/docs/investmoz/zones/validation.md new file mode 100644 index 00000000..2747deeb --- /dev/null +++ b/docs/investmoz/zones/validation.md @@ -0,0 +1,21 @@ +# Validação do núcleo — 28/09/2026 + +Suite completa em PostgreSQL/PostGIS isolado: **144 testes, 1.567 verificações aprovadas**, 27,372 s. PHPStan completo sem erros e TypeScript aprovados. Lint dos componentes investment, portfolio/show, navegação e gestão aprovado; Pint aplicado aos novos ficheiros PHP. Build final aprovado, 2.358 módulos, 6,80 s. Teste focado posterior confirmou também o transporte POST com `_method=PUT` utilizado pelo editor: seis testes, 104 verificações aprovadas. + +`InvestmentZoneTest` contém seis cenários: acesso/MFA, versões e snapshot encriptado imutável; rollback vazio em ordem inversa e recusa com dados; uma oportunidade em duas zonas, fonte revogada e origem de interesse com protocolo idempotente; geometria inválida e revisão independente; uma âncora em ZEE/ZFI/parque, sem duplicação; dry-run/aplicação repetível de identidades sem alterar os legados. + +A suite inclui os testes anteriores de API, consentimento, privacidade, anexos, concorrência e navegação. Não representa auditoria jurídica, pentest integral ou prova de carga em produção. + +[Navegador](browser-validation.json): onze observações públicas em Edge, larguras 390 e 1440, para ZEE, ZFI, parques, catálogo e lista–mapa. Breadcrumbs, apoio, quatro entradas e ausência de transbordo conferidos; zero erros JavaScript. A última observação bloqueia a chamada do mapa e confirma que a lista continua disponível. O ensaio utiliza os estados vazios reais das zonas e o catálogo existente, sem inserir dados fictícios na base operacional. As novas jornadas autenticadas foram verificadas por HTTP/PHPUnit; não declarar UAT ou teste assistivo completo a partir deste smoke test. + +Contrato [OpenAPI v2](openapi.json): 18 operações correspondentes às 18 rotas registadas; gerador `tools/zones-openapi.mjs`. As respostas têm esquema aberto e exigem tipagem/validação externa adicional antes de certificação de contrato completa. API v1 conserva rotas e payloads existentes e documenta os campos opcionais de origem/filtros das zonas. + +## Repetir + +Usar os comandos/ambiente isolado em [baseline.md](baseline.md). Teste focado: `php vendor/bin/phpunit --filter InvestmentZoneTest --do-not-cache-result`, com o PHP validado e PostgreSQL QA na porta 55441. Não usar a base operacional com RefreshDatabase. + +Build: `npm.cmd run build`, depois de terminar PHPUnit. Navegador público: `node tools/zones-browser-smoke.mjs`, com servidor local 8000 e Edge de QA na porta 9224. Não confundir disponibilidade dessas ferramentas locais com infra-estrutura de produção. + +## Gate factual + +Go para execução local do núcleo descrito e continuação automática das tarefas autorizadas. No-Go para declarar todas as fases concluídas ou publicar dados jurídicos como oficiais: ver [pendências por fase](implementation-status.md). Não houve chamada real a Alfândegas/SuiteCRM, envio de email, alteração de taxas ou publicação de zona oficial. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..61fc9064 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6716 @@ +{ + "name": "portal-investidor", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@inertiajs/react": "^3.0.0", + "@inertiajs/vite": "^3.0.0", + "@laravel/passkeys": "^0.2.0", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-navigation-menu": "^1.2.5", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", + "@tailwindcss/vite": "^4.1.11", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.1.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "concurrently": "^10.0.3", + "input-otp": "^1.4.2", + "laravel-vite-plugin": "^3.0.0", + "lucide-react": "^0.475.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "sonner": "^2.0.0", + "tailwind-merge": "^3.0.1", + "tailwindcss": "^4.0.0", + "tw-animate-css": "^1.4.0", + "typescript": "^5.7.2", + "vite": "^8.0.0" + }, + "devDependencies": { + "@laravel/vite-plugin-wayfinder": "^0.1.3", + "@rolldown/plugin-babel": "^0.2.3", + "@types/node": "^22.13.5", + "babel-plugin-react-compiler": "^1.0.0", + "vite-plus": "0.3.0" + }, + "optionalDependencies": { + "@laravel/multiplex": "^0.4.1", + "@rollup/rollup-linux-x64-gnu": "4.9.5", + "@rollup/rollup-win32-x64-msvc": "4.9.5", + "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", + "@tailwindcss/oxide-win32-x64-msvc": "^4.0.1", + "lightningcss-linux-x64-gnu": "^1.29.1", + "lightningcss-win32-x64-msvc": "^1.29.1" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", + "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.0.tgz", + "integrity": "sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.1.tgz", + "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-compilation-targets": "^8.0.0", + "@babel/helpers": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/template": "^8.0.0", + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0", + "@types/gensync": "^1.0.5", + "convert-source-map": "^2.0.0", + "empathic": "^2.0.1", + "gensync": "^1.0.0-beta.2", + "import-meta-resolve": "^4.2.0", + "json5": "^2.2.3", + "obug": "^2.1.1", + "semver": "^7.7.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.0.tgz", + "integrity": "sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/compat-data": "^8.0.0", + "@babel/helper-validator-option": "^8.0.0", + "browserslist": "^4.24.0", + "lru-cache": "^11.0.0", + "semver": "^7.7.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helpers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.0.tgz", + "integrity": "sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.4.tgz", + "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.4", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.4", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@inertiajs/core": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.7.0.tgz", + "integrity": "sha512-JzysXTPsOpKcnR7ohwpgKE+UWBrKwW7z23DydWHSxjfQ02tJAKmNnZJAxnX7zH3zUSTqbtPge6QC8XhvfjNXmw==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "es-toolkit": "^1.33.0", + "laravel-precognition": "^2.0.0" + }, + "peerDependencies": { + "axios": "^1.15.2" + }, + "peerDependenciesMeta": { + "axios": { + "optional": true + } + } + }, + "node_modules/@inertiajs/react": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-3.7.0.tgz", + "integrity": "sha512-rc/TsVT7ihDk+cMeCuU3BjdUfTq6HJwXqGy7oPaysLQCeyfhIaP2QgExo5R1QSCFIwl9un0MeZnzTOG+i38jLw==", + "license": "MIT", + "dependencies": { + "@inertiajs/core": "3.7.0", + "es-toolkit": "^1.33.0", + "laravel-precognition": "^2.0.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@inertiajs/vite": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@inertiajs/vite/-/vite-3.7.0.tgz", + "integrity": "sha512-eoOqvIEgmsktC/b5NuZjsj3VvCJ7GsbvHFN2IgT/7DXjLVdXGygWeyYHmS57AGSbb6yOyebeBR6cYkk6YyHOmg==", + "license": "MIT", + "dependencies": { + "@inertiajs/core": "3.7.0", + "tinyglobby": "^0.2.15" + }, + "peerDependencies": { + "vite": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@laravel/multiplex": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@laravel/multiplex/-/multiplex-0.4.3.tgz", + "integrity": "sha512-jJTZGgKl6sK0ETYmqiPqeAwmErvqjxgDRLfzqsmqiWiupc5dfjaoVVrrdXZwb6eGylSg99WwHLibCPJs8F0bGw==", + "license": "MIT", + "optional": true, + "os": [ + "darwin", + "linux" + ], + "dependencies": { + "ansi-styles": "^6.2.3", + "commander": "^15.0.0", + "ink": "^7.1.0", + "react": "^19.2.7", + "string-width": "^8.2.2", + "wrap-ansi": "^10.0.0" + }, + "bin": { + "multiplex": "dist/cli.js" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/@laravel/passkeys": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@laravel/passkeys/-/passkeys-0.2.0.tgz", + "integrity": "sha512-K+owv26weKrYaoTJutqU40UsJZpwYkqB9jdlz5Bv/C54nxj2+Py9jbVwxASWg21fLsPhn3ngPKZE5RnfakCQKg==", + "license": "MIT", + "dependencies": { + "@simplewebauthn/browser": "^13.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "svelte": "^5.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@laravel/vite-plugin-wayfinder": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@laravel/vite-plugin-wayfinder/-/vite-plugin-wayfinder-0.1.10.tgz", + "integrity": "sha512-59ZCMCEeC1UcZkqbvn+G06C4D0C/jCm525gTft7T7RPAo+0ZaHtoKl9r86OFVFd/BwuJiOYky4zJUrAPRKyRaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/runtime": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.146.0.tgz", + "integrity": "sha512-lbXHIpZ1MmK6zuw5txlMdIZ2waLVUIU5Gnm3sEuwJOiqDfQfbtjeHscatmeBoxbv8+If9LFM6PGh/3DcDWYIYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.64.0.tgz", + "integrity": "sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.64.0.tgz", + "integrity": "sha512-jRGSUeeP7p3Gynw2YaCVtjBIA6ZxY6bEB/ES5i54OhqmRTyuVg7ZgstEtzgq6GOAJd+2QZ5pvf+bFfmW5Mp9cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.64.0.tgz", + "integrity": "sha512-JINwtU2lW7nOFSqi+H2qplipNUqah9Gc1jgGmB82kTD4UnZrZIVxCJ9qEmFiKfjNq27gYLFhrUb0to86aCwMjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.64.0.tgz", + "integrity": "sha512-gCmuswrgrOSajV4HCRFkVCGIruPq8bjYuPYgSE2WQB3mD6XrdyZ3JMSRZCkQ8zCxOyGWriBo6QoZ5nmMHQ1BfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.64.0.tgz", + "integrity": "sha512-Ab8g7a38pT0MMImjh7anRSTve6buWBIlcXIFBYa5xl4s6UxEgKSc2xOOhbGtLwvXnEi2PsEDGoJh3oUU7xkehQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.64.0.tgz", + "integrity": "sha512-BgvS3CoQ+Xy2deoZqEN8JVKabcCZi2RxA3yant8G9OAv9KuPJ9TCjHkqigzdHUVwErZxEP5d2bzLIEyKYyBDLg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.64.0.tgz", + "integrity": "sha512-QXpNxwoMj0YvnceCNZadNSden3bIcnvjn/sDp/rwZhRoZoZYGpHvtPyhGsdJz9uvT9GkaMW7SsLddurU56dt8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.64.0.tgz", + "integrity": "sha512-BBgH3I1ppDsI5pZ4Pdhw0ceYxwVCfbU/bZEBCeZ6caRS9x0ZabErxubP7riGUn11PXZBhe8DYdjkDKP1FlVQ5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.64.0.tgz", + "integrity": "sha512-v19HSjC/BGXdt26qEvKZtwAHgGmQ2Agcap2kQP+KIqoRZqivVzYth3ui2dJA1i+6/fjpjga85lIOaJJjQ/bOOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.64.0.tgz", + "integrity": "sha512-PElLnOo4xFTBZrxPhgTIj0eHqZXwEBQoNWtb7facUV170T0B0FRET0iNbb3LUeLWTybkUW+vsdyv4ihOdyXGyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.64.0.tgz", + "integrity": "sha512-Qzsg15n4F5CH+MorcRW4MkAEMiLzXmeG+DiDSbP/bBTqCmWOH3K9DHryNrve+JHlV0txS+B6Z9P5Xz+cmWeL+g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.64.0.tgz", + "integrity": "sha512-/GZ358wnQ/Ez4UVnCcZIi56JkY0sOdZ+B108pqXKqZz3jLS59F4KEAB1Qv3fRlObrFEk+3L2vUQ/xoPx+3vjXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.64.0.tgz", + "integrity": "sha512-/C9We3DXegowfLXtVCYHeNiU9azwCDr5cQkEtCVlc74vyn+lLQSPApJ1CZmxAduqeq/Oi3gQ+IVptyhCaTMtkQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.64.0.tgz", + "integrity": "sha512-91KM2CeRWscIEHlj1NsW2WSnzGeq1Ehq+39bfDowTdkn+fcvK/x4Y1RcyqT7glyBjZio0ldkeCG6Usj3v7ASog==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.64.0.tgz", + "integrity": "sha512-gw7uEk9I+7zoT1EYLra1eWArIzNcz8e3jkv+Noo2+o2T7wPvsNSQbfoa4DSfZlvn1i6mJ05RiZ4/omaXPDNhQg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.64.0.tgz", + "integrity": "sha512-HYHFf616FHSPSO07c09mjmXBfQ73wIVM3m0txOiooa5XZkGoxFd6B14PVj0LB0DXIqJ6wAO/dDR/NX/5UUaqnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.64.0.tgz", + "integrity": "sha512-uQjFp081IZSWD6VAofX2iO2z01awAdHmfC+NrieWIPKrT2hZKQDyq/U18M7ifC0sm0Wz8aHY/p6+FDYIzs/CrQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.64.0.tgz", + "integrity": "sha512-lNM6byTAQ881jugzFu8juJTbNRgsUTlswMA6pJmwi1XDvmIqnnb49lcUAs5gz94fCJLrVN+/X3s3jOKqx23WIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.64.0.tgz", + "integrity": "sha512-BtmbtL/QjMtF1a6C3CqoDluH2IfB6fJt62E+B9RFfUPtFk4Iz9PFS6+y/SzzOvSxc7aUk2Kphwg7Dh8lMbwu6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint-tsgolint/darwin-arm64": { + "version": "7.0.2001", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2001.tgz", + "integrity": "sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/darwin-x64": { + "version": "7.0.2001", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-7.0.2001.tgz", + "integrity": "sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/linux-arm64": { + "version": "7.0.2001", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-7.0.2001.tgz", + "integrity": "sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/linux-x64": { + "version": "7.0.2001", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-7.0.2001.tgz", + "integrity": "sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/win32-arm64": { + "version": "7.0.2001", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-7.0.2001.tgz", + "integrity": "sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint-tsgolint/win32-x64": { + "version": "7.0.2001", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2001.tgz", + "integrity": "sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.79.0.tgz", + "integrity": "sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.79.0.tgz", + "integrity": "sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.79.0.tgz", + "integrity": "sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.79.0.tgz", + "integrity": "sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.79.0.tgz", + "integrity": "sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.79.0.tgz", + "integrity": "sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.79.0.tgz", + "integrity": "sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.79.0.tgz", + "integrity": "sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.79.0.tgz", + "integrity": "sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.79.0.tgz", + "integrity": "sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.79.0.tgz", + "integrity": "sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.79.0.tgz", + "integrity": "sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.79.0.tgz", + "integrity": "sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.79.0.tgz", + "integrity": "sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.79.0.tgz", + "integrity": "sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.79.0.tgz", + "integrity": "sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.79.0.tgz", + "integrity": "sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.79.0.tgz", + "integrity": "sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.79.0.tgz", + "integrity": "sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/plugins": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/@oxlint/plugins/-/plugins-1.79.0.tgz", + "integrity": "sha512-S0uyoxakDINJ4DPgqxGlEEvrdSMeQb7Z2lKVjxoY2gwsbZbfg2Xr8Klfeo5ZeraHmmdBCELFUHkSe6KEmBpMvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", + "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.22", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.22.tgz", + "integrity": "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", + "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", + "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/plugin-babel": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/plugin-babel/-/plugin-babel-0.2.4.tgz", + "integrity": "sha512-ygmm2j/mN+aCvstHo0x3IOu4uc6LqXT9SJ3q4kpfG/+Yhfz0B+E47FITcC0FljZADwUFARP6E2kiE+Lc26D8wg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "picomatch": "^4.0.7" + }, + "engines": { + "node": ">=22.12.0 || ^24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.29.0 || ^8.0.0-rc.1", + "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", + "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", + "rolldown": "^1.0.0-rc.5", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@babel/plugin-transform-runtime": { + "optional": true + }, + "@babel/runtime": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.5.tgz", + "integrity": "sha512-Dq1bqBdLaZ1Gb/l2e5/+o3B18+8TI9ANlA1SkejZqDgdU/jK/ThYaMPMJpVMMXy2uRHvGKbkz9vheVGdq3cJfA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.5.tgz", + "integrity": "sha512-1q+mykKE3Vot1kaFJIDoUFv5TuW+QQVaf2FmTT9krg86pQrGStOSJJ0Zil7CFagyxDuouTepzt5Y5TVzyajOdQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@testing-library/dom/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@testing-library/dom/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/gensync": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", + "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", + "devOptional": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "devOptional": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/browser": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.11.tgz", + "integrity": "sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.11" + } + }, + "node_modules/@vitest/browser-preview": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser-preview/-/browser-preview-4.1.11.tgz", + "integrity": "sha512-iPKSE6Ibayey6HFgK1V1/aHgyhx7HSRk1YMi+lnBZGmlIiNV5Uc7xRkD9Su8RDylTxDECK23t7kTHdRKoqSYDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/user-event": "^14.6.1", + "@vitest/browser": "4.1.11" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.11" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@voidzero-dev/vite-plus-core": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-core/-/vite-plus-core-0.3.0.tgz", + "integrity": "sha512-aOqoqIWaF+Q/geDU48pC2rVFEVSvLV1GGj/NdvhUiBhCZntoFNbwI+hjUeG8BMaPG67sOV6ey+/sgkdmGmKqaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/runtime": "=0.146.0", + "@oxc-project/types": "=0.146.0", + "lightningcss": "^1.33.0", + "postcss": "^8.5.6", + "yuku-codegen": "^0.5.44", + "yuku-parser": "^0.5.44" + }, + "engines": { + "node": "^20.19.0 || ^22.18.0 || >=24.11.0" + }, + "optionalDependencies": { + "@voidzero-dev/vite-plus-darwin-arm64": "0.3.0", + "@voidzero-dev/vite-plus-darwin-x64": "0.3.0", + "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.0", + "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.0", + "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.0", + "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.0", + "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.0", + "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.0", + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@arethetypeswrong/core": "^0.18.1", + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "publint": "^0.3.8", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", + "unplugin-unused": "^0.5.0", + "unrun": "*", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@arethetypeswrong/core": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "publint": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "typescript": { + "optional": true + }, + "unplugin-unused": { + "optional": true + }, + "unrun": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/@voidzero-dev/vite-plus-core/node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@voidzero-dev/vite-plus-core/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-core/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@voidzero-dev/vite-plus-core/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@voidzero-dev/vite-plus-core/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@voidzero-dev/vite-plus-core/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@voidzero-dev/vite-plus-core/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@voidzero-dev/vite-plus-core/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@voidzero-dev/vite-plus-core/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@voidzero-dev/vite-plus-core/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@voidzero-dev/vite-plus-core/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@voidzero-dev/vite-plus-darwin-arm64": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-arm64/-/vite-plus-darwin-arm64-0.3.0.tgz", + "integrity": "sha512-9ADr1egZ8T4tJOqrpQLhoDl95Y74R95+bsvjmin0gy1C0eQVhpmcNnBfb07KFNhJioJp9MMO7F7Dx4fQL5SKsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-darwin-x64": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-x64/-/vite-plus-darwin-x64-0.3.0.tgz", + "integrity": "sha512-GegasVCwNeDOkNyvhLOuwU1+T2JkjY/Tq+SOvwphUpVcqQ6OOAUq9LlpoXviO2QL/Kq2NbMYjiAfPKVSTLUFQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-linux-arm64-gnu": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-gnu/-/vite-plus-linux-arm64-gnu-0.3.0.tgz", + "integrity": "sha512-nYI3KNYXkXjRPsSdR4Lr7J2xMxfR1+TplWlG/dV37qVXWAjbyHpoAlbULjZBAVJMyXRNlcADhBrEwXe4g6s48A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-linux-arm64-musl": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-musl/-/vite-plus-linux-arm64-musl-0.3.0.tgz", + "integrity": "sha512-HRlVA3AOcuGXmOdHhQ+Zv5XAaKbYF9si5rRHoOsKl0UyBo4txA3OoJfmP0WjanfLUNmu85JyO2dO1ptL4C6wgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-linux-x64-gnu": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-gnu/-/vite-plus-linux-x64-gnu-0.3.0.tgz", + "integrity": "sha512-9A+dFScPfwcrzF/rRR0zH8++2hOf6xtFmN/5LyzyfUywtw9MILXcC72IMcOeL6QRJwKUMsudi1rFeDE59azNvw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-linux-x64-musl": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-musl/-/vite-plus-linux-x64-musl-0.3.0.tgz", + "integrity": "sha512-KfIV3qaPdaOOE8JQMRHRE34FtZocl9O86XLTP6JMjDUlcx8FPgf8/fz/HFqJ8g232vM+JsgLI/YTVeXP8LkTKw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-win32-arm64-msvc": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-arm64-msvc/-/vite-plus-win32-arm64-msvc-0.3.0.tgz", + "integrity": "sha512-KRhdy5K13AYx9KBfCVHRrK7zSZU+bMW9CL6gTai+UkJgAmDJi1kjdSNboZOjO8mrzUnTCrELgMI2tnstcxSTuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@voidzero-dev/vite-plus-win32-x64-msvc": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-x64-msvc/-/vite-plus-win32-x64-msvc-0.3.0.tgz", + "integrity": "sha512-7+G+GxGmxdpQO0zjiGnkZFXKGqm0CrVduebRsJd6ccuOuxCQYPxLcoHq4WOaGrh56SrAGS7XjhnQCrXRkzKUVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@yuku-codegen/binding-darwin-arm64": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-darwin-arm64/-/binding-darwin-arm64-0.5.48.tgz", + "integrity": "sha512-yo96Oef12WzqnphInfz/eexVse3+kWgfGS5g2S3rFS3dcGn1ENW9xLFDZUP9rh+yP76DOq38wBoFi1+I9+6qBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-codegen/binding-darwin-x64": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-darwin-x64/-/binding-darwin-x64-0.5.48.tgz", + "integrity": "sha512-aRCTw0EZC4bVosmw//0OMYP5tGWFE0Cu5yUBFkUbhXx/iBzvORcJ2xPNlOp/vtCCo9Ys4vp8b0DigJV6uOVb2g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-codegen/binding-freebsd-x64": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-freebsd-x64/-/binding-freebsd-x64-0.5.48.tgz", + "integrity": "sha512-CA0AQAEApDkbw51PdLWMtKPJ41/7rvXsS3SJs+phG7fHJI+MuFzWuLbkucZfZoEOiDscmcsfYIdgL8BsfuyKKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@yuku-codegen/binding-linux-arm-gnu": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.5.48.tgz", + "integrity": "sha512-DuSQlk8bH4gpmW3/00P0NLagAcMv8jOxjT40cQmxKRkktr+SUOALCfkT89tdDq3qtY95NR2GXOZ7AjNh7KKqCw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-arm-musl": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm-musl/-/binding-linux-arm-musl-0.5.48.tgz", + "integrity": "sha512-bxj4Ee+wlaJcWJwft2ReJXWw5sfl1qavDz6+dlRdU1xfTEtjPSNiAWhiCHnJR0R4Ygd57DnzSQmAVGvFv6RcGw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-arm64-gnu": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.5.48.tgz", + "integrity": "sha512-mk5JVWh+0JOe5ue8k17kbYX8uGBoKt3ZqoCyxNh4nYAAcX7+X1tFUiU7jbjctu4vHeejCBFSTdQ021+V31cUCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-arm64-musl": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.5.48.tgz", + "integrity": "sha512-4q3vkrNghbllyxOm2KesFLxCPKHF7r3JyQ7BWZccY1j2Y05yKoIFhoWCqIuQ2W/dpte9RI0+OVfwyxnrKg6fkA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-x64-gnu": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.5.48.tgz", + "integrity": "sha512-csd4M1EVrGaohM8acM6gq1zpUA/Rwe2ulUMBKUcwQXm/k6n7cq1A++qdew78SOVb4do3JH1WE+WFwoGQAcWc1w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-x64-musl": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-x64-musl/-/binding-linux-x64-musl-0.5.48.tgz", + "integrity": "sha512-KcDuEOT+GFoVKdvAWOv1v9iYjwnmvMZlO+j1Rw+5PYdeFLGWGzv/DD11y4SAAdwXIFcil4T0hibeIaF82WStMg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-win32-arm64": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-win32-arm64/-/binding-win32-arm64-0.5.48.tgz", + "integrity": "sha512-HI8qNrI8dWM5BuqIMKsqornRvTNFrE6sm5zToIJ9YIa9zt5+29P7fJ7Nr39EVf6dAWSb6q7JSpScJnRsQ+FgZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-codegen/binding-win32-x64": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-win32-x64/-/binding-win32-x64-0.5.48.tgz", + "integrity": "sha512-X5YWJLO6EfBZpeBqO0AYESnUizbpFDWArcvVD61w0PEWQ3CaFRLnbQXs+kpM4ZZfGMfIE22zfA08QSY67q7TNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-parser/binding-darwin-arm64": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.5.48.tgz", + "integrity": "sha512-If8mb7HH3vqghJ2NNZ8SuHfhsnjVzOxJpB8xcNOXS5WjYrs2mUhHIh5KOIvK13hDOzh0htGeGK3A6MsiEqE7HQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-parser/binding-darwin-x64": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-darwin-x64/-/binding-darwin-x64-0.5.48.tgz", + "integrity": "sha512-EimvPXfspzxf1K11eB6tCW5oiQEXB8g84T2wP1TwzQagdDKo33bkmmVF0B32vTIpXnk/Ifu5IB61izZ1MylljA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-parser/binding-freebsd-x64": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.5.48.tgz", + "integrity": "sha512-0GcUMrumLHheThY9r5Tp46gaZYzn0irWPS1Zba6WY+vVQfhUtzGiWgXxI6tuXX0N32kEaaEVRpkKctvo6Kx3aQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@yuku-parser/binding-linux-arm-gnu": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.5.48.tgz", + "integrity": "sha512-8S5T5wjCC73dmmpQeZ49aYsSunIUM3D4Fc6rdK96c+Ayg/p3FmeSPF3xuLZHejcTmqJIIvnbfPlUF+rB6DITjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-arm-musl": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm-musl/-/binding-linux-arm-musl-0.5.48.tgz", + "integrity": "sha512-tTmbxvnUHcK2/crS9547vk2SMmsajH1yqJ8ltXhIuHJgqR1v+d9n9KT+kSayo/5CS76LegeYxhMFjEivBH2hFA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-arm64-gnu": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.5.48.tgz", + "integrity": "sha512-KGYCBMqI2zfwyhgq5tpPVNe7jpUeYTBm8DhjdS+zqWNumde/PEC170QE5RHxcOAlsirIDeIUk0jqx+r/axoFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-arm64-musl": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.5.48.tgz", + "integrity": "sha512-2wTSMsCSXLTc2lZUjMAuU5X4cje55u205WJqfV5NWNF6j9pW/tXyxr15dJeekj8ziLqBXzIsj4DbRh4sY/WcjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-x64-gnu": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.5.48.tgz", + "integrity": "sha512-d/6v9UnGglVu1WC2JQyv/5aWSi5fXZeGSlidCfmHp4+N65N1GDKUnFtys5MK5eAPeAjTgSHGGtOc/yCcKTlv3A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-x64-musl": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.5.48.tgz", + "integrity": "sha512-gX19gw6u4ApPy7SYMPKfFlEkrtj6WlORvrTKK3sBQqjyV+8+mUAkQgxXNjHw4RnOiAmVYg7TOlZcg8d+Qqod9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-win32-arm64": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-win32-arm64/-/binding-win32-arm64-0.5.48.tgz", + "integrity": "sha512-w6cQQLbqj3Jcom5Q7ifm103NUOQ9d+Cb4VU5lkrZDjMnwVJ9Hzzg1vCQR7miJuF44vhCXldbme5UryE3giEKlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-parser/binding-win32-x64": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-win32-x64/-/binding-win32-x64-0.5.48.tgz", + "integrity": "sha512-4gO0HmG7fzFxrw1rs0dUdnnaY9YgennjETqDWrTSp7x9fmTUOAoN4VsMfP7YyliQeG1WJJHc55O+rOhmsLppow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-toolchain/types": { + "version": "0.5.43", + "resolved": "https://registry.npmjs.org/@yuku-toolchain/types/-/types-0.5.43.tgz", + "integrity": "sha512-kSpvPntnXw5+lYjO71ffBEnQ5ycQ74KGIYknh0TS4xeyCuBkOqxyJumxZkMhLBBUCLjDAbx2+Icnr3Zh4ftjpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "optional": true, + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/babel-plugin-react-compiler/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/babel-plugin-react-compiler/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/babel-plugin-react-compiler/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "devOptional": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "devOptional": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0", + "peer": true + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "optional": true, + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", + "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "optional": true, + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/concurrently": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", + "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.423", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.423.tgz", + "integrity": "sha512-rRZfTSY8ptHYMQxa+uIycJMFKmY1T0GIApNMXJYGehguTZa56TEEl19pKPCoBqk5Gpf7QizZn/jt7xur+DYxag==", + "devOptional": true, + "license": "ISC", + "peer": true + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", + "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz", + "integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.3.0", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.3", + "auto-bind": "^5.0.1", + "chalk": "^5.6.2", + "cli-boxes": "^4.0.1", + "cli-cursor": "^4.0.0", + "cli-truncate": "^6.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.45.1", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^9.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.2.0", + "terminal-size": "^4.0.1", + "type-fest": "^5.5.0", + "widest-line": "^6.0.0", + "wrap-ansi": "^10.0.0", + "ws": "^8.20.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": ">=19.2.0", + "react": ">=19.2.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/input-otp": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.5.0.tgz", + "integrity": "sha512-3AcfdW1sNG0FmSA5hHMBXG7jNW5CdcdKs8ln8JOmn6S2SRkoXoJx+2UwC+ZvjflfnOdHJx0SfQ9/YjlxVYLtGQ==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "optional": true, + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "devOptional": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/laravel-precognition": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz", + "integrity": "sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==", + "license": "MIT", + "dependencies": { + "es-toolkit": "^1.32.0" + }, + "peerDependencies": { + "axios": "^1.4.0" + }, + "peerDependenciesMeta": { + "axios": { + "optional": true + } + } + }, + "node_modules/laravel-vite-plugin": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz", + "integrity": "sha512-xSxY9Gzeb/eancd8WeK09piAFP+a6i5QIBqNCKNv9L0Eq6wziwzSem7F1GvMSrtjMh5F/QVKFxn8t9naGOA66A==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "tinyglobby": "^0.2.12", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "fontaine": "^0.8.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "fontaine": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "devOptional": true, + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lucide-react": { + "version": "0.475.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz", + "integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "devOptional": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oxfmt": { + "version": "0.64.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.64.0.tgz", + "integrity": "sha512-XZ4GFBN/PLbXKq+0zrgpQfPKYuJlUuj+nzZJY7UpIbFMNyefNLCdN9EwViycNqnYcv0wrn0jXcQLlqJp8RCKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.64.0", + "@oxfmt/binding-android-arm64": "0.64.0", + "@oxfmt/binding-darwin-arm64": "0.64.0", + "@oxfmt/binding-darwin-x64": "0.64.0", + "@oxfmt/binding-freebsd-x64": "0.64.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.64.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.64.0", + "@oxfmt/binding-linux-arm64-gnu": "0.64.0", + "@oxfmt/binding-linux-arm64-musl": "0.64.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.64.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.64.0", + "@oxfmt/binding-linux-riscv64-musl": "0.64.0", + "@oxfmt/binding-linux-s390x-gnu": "0.64.0", + "@oxfmt/binding-linux-x64-gnu": "0.64.0", + "@oxfmt/binding-linux-x64-musl": "0.64.0", + "@oxfmt/binding-openharmony-arm64": "0.64.0", + "@oxfmt/binding-win32-arm64-msvc": "0.64.0", + "@oxfmt/binding-win32-ia32-msvc": "0.64.0", + "@oxfmt/binding-win32-x64-msvc": "0.64.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint": { + "version": "1.79.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.79.0.tgz", + "integrity": "sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.79.0", + "@oxlint/binding-android-arm64": "1.79.0", + "@oxlint/binding-darwin-arm64": "1.79.0", + "@oxlint/binding-darwin-x64": "1.79.0", + "@oxlint/binding-freebsd-x64": "1.79.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.79.0", + "@oxlint/binding-linux-arm-musleabihf": "1.79.0", + "@oxlint/binding-linux-arm64-gnu": "1.79.0", + "@oxlint/binding-linux-arm64-musl": "1.79.0", + "@oxlint/binding-linux-ppc64-gnu": "1.79.0", + "@oxlint/binding-linux-riscv64-gnu": "1.79.0", + "@oxlint/binding-linux-riscv64-musl": "1.79.0", + "@oxlint/binding-linux-s390x-gnu": "1.79.0", + "@oxlint/binding-linux-x64-gnu": "1.79.0", + "@oxlint/binding-linux-x64-musl": "1.79.0", + "@oxlint/binding-openharmony-arm64": "1.79.0", + "@oxlint/binding-win32-arm64-msvc": "1.79.0", + "@oxlint/binding-win32-ia32-msvc": "1.79.0", + "@oxlint/binding-win32-x64-msvc": "1.79.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint-tsgolint": { + "version": "7.0.2001", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-7.0.2001.tgz", + "integrity": "sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==", + "dev": true, + "license": "MIT", + "bin": { + "tsgolint": "bin/tsgolint.js" + }, + "optionalDependencies": { + "@oxlint-tsgolint/darwin-arm64": "7.0.2001", + "@oxlint-tsgolint/darwin-x64": "7.0.2001", + "@oxlint-tsgolint/linux-arm64": "7.0.2001", + "@oxlint-tsgolint/linux-x64": "7.0.2001", + "@oxlint-tsgolint/win32-arm64": "7.0.2001", + "@oxlint-tsgolint/win32-x64": "7.0.2001" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/slice-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "optional": true, + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type-fest": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz", + "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "devOptional": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite-plus": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/vite-plus/-/vite-plus-0.3.0.tgz", + "integrity": "sha512-GNWbWuWD37frCSFrz6MLzUo62bTv5IOJozHEgZYOkxsLkuQtTwm4TowzpfoGrSsfwhAAtfPd/sK1Y0+v1SwhZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@oxlint/plugins": "=1.79.0", + "@vitest/browser": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "@voidzero-dev/vite-plus-core": "0.3.0", + "oxfmt": "=0.64.0", + "oxlint": "=1.79.0", + "oxlint-tsgolint": "=7.0.2001", + "vitest": "4.1.11" + }, + "bin": { + "oxfmt": "bin/oxfmt", + "oxlint": "bin/oxlint", + "vp": "bin/vp", + "vpr": "bin/vpr" + }, + "engines": { + "node": "^20.19.0 || ^22.18.0 || >=24.11.0" + }, + "optionalDependencies": { + "@voidzero-dev/vite-plus-darwin-arm64": "0.3.0", + "@voidzero-dev/vite-plus-darwin-x64": "0.3.0", + "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.0", + "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.0", + "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.0", + "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.0", + "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.0", + "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.0" + }, + "peerDependencies": { + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + } + } + }, + "node_modules/vite-plus/node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.1.tgz", + "integrity": "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT", + "optional": true + }, + "node_modules/yuku-codegen": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/yuku-codegen/-/yuku-codegen-0.5.48.tgz", + "integrity": "sha512-p7HxD5Xl4jzDzqMrGePAOeSHmRY4g58h4HuGq15weQFPxuPWd/W6e7nqp/+Lea6JfpOdBwJOAyXFqIZ/J9Zfnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "0.5.43" + }, + "optionalDependencies": { + "@yuku-codegen/binding-darwin-arm64": "0.5.48", + "@yuku-codegen/binding-darwin-x64": "0.5.48", + "@yuku-codegen/binding-freebsd-x64": "0.5.48", + "@yuku-codegen/binding-linux-arm-gnu": "0.5.48", + "@yuku-codegen/binding-linux-arm-musl": "0.5.48", + "@yuku-codegen/binding-linux-arm64-gnu": "0.5.48", + "@yuku-codegen/binding-linux-arm64-musl": "0.5.48", + "@yuku-codegen/binding-linux-x64-gnu": "0.5.48", + "@yuku-codegen/binding-linux-x64-musl": "0.5.48", + "@yuku-codegen/binding-win32-arm64": "0.5.48", + "@yuku-codegen/binding-win32-x64": "0.5.48" + } + }, + "node_modules/yuku-parser": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/yuku-parser/-/yuku-parser-0.5.48.tgz", + "integrity": "sha512-OWBfhrpgK9+/4+IXG9oT8Bao4AhViQA7vdyNNH7EUg8dQYgwa70XtIBWTpCEme1P1ECyoDNYkn0wT63f8XRcVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "0.5.43" + }, + "optionalDependencies": { + "@yuku-parser/binding-darwin-arm64": "0.5.48", + "@yuku-parser/binding-darwin-x64": "0.5.48", + "@yuku-parser/binding-freebsd-x64": "0.5.48", + "@yuku-parser/binding-linux-arm-gnu": "0.5.48", + "@yuku-parser/binding-linux-arm-musl": "0.5.48", + "@yuku-parser/binding-linux-arm64-gnu": "0.5.48", + "@yuku-parser/binding-linux-arm64-musl": "0.5.48", + "@yuku-parser/binding-linux-x64-gnu": "0.5.48", + "@yuku-parser/binding-linux-x64-musl": "0.5.48", + "@yuku-parser/binding-win32-arm64": "0.5.48", + "@yuku-parser/binding-win32-x64": "0.5.48" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..3a536df8 --- /dev/null +++ b/package.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vp build", + "build:ssr": "vp build && vp build --ssr", + "dev": "vp dev", + "check": "vp check", + "check:fix": "vp check --fix", + "types:check": "tsc --noEmit" + }, + "dependencies": { + "@inertiajs/react": "^3.0.0", + "@inertiajs/vite": "^3.0.0", + "@laravel/passkeys": "^0.2.0", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-navigation-menu": "^1.2.5", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", + "@tailwindcss/vite": "^4.1.11", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.1.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "concurrently": "^10.0.3", + "input-otp": "^1.4.2", + "laravel-vite-plugin": "^3.0.0", + "lucide-react": "^0.475.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "sonner": "^2.0.0", + "tailwind-merge": "^3.0.1", + "tailwindcss": "^4.0.0", + "tw-animate-css": "^1.4.0", + "typescript": "^5.7.2", + "vite": "^8.0.0" + }, + "devDependencies": { + "@laravel/vite-plugin-wayfinder": "^0.1.3", + "@rolldown/plugin-babel": "^0.2.3", + "@types/node": "^22.13.5", + "babel-plugin-react-compiler": "^1.0.0", + "vite-plus": "0.3.0" + }, + "optionalDependencies": { + "@laravel/multiplex": "^0.4.1", + "@rollup/rollup-linux-x64-gnu": "4.9.5", + "@rollup/rollup-win32-x64-msvc": "4.9.5", + "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", + "@tailwindcss/oxide-win32-x64-msvc": "^4.0.1", + "lightningcss-linux-x64-gnu": "^1.29.1", + "lightningcss-win32-x64-msvc": "^1.29.1" + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..1ca23a42 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,13 @@ +includes: + - vendor/larastan/larastan/extension.neon + - vendor/nesbot/carbon/extension.neon + +parameters: + paths: + - app/ + - bootstrap/app.php + - config/ + - database/ + - routes/ + + level: 7 diff --git a/phpunit.investmoz.xml b/phpunit.investmoz.xml new file mode 100644 index 00000000..5ae43967 --- /dev/null +++ b/phpunit.investmoz.xml @@ -0,0 +1,18 @@ + + + + + tests/InvestMoz + + + + + + + + + + + + + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..db9b9c7e --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 00000000..93061b6b --- /dev/null +++ b/pint.json @@ -0,0 +1,3 @@ +{ + "preset": "laravel" +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..3d4f4515 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - '.' + +publicHoistPattern: + - '@inertiajs/core' diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 00000000..b574a597 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 00000000..c2efef6d Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..236fadb9 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 00000000..e4e710e0 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 00000000..ee8f07e9 --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 00000000..eb053628 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 00000000..7015d586 --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,143 @@ +@import 'tailwindcss'; + +@import 'tw-animate-css'; + +@source '../views'; +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --font-sans: + 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + --color-background: var(--background); + --color-foreground: var(--foreground); + + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.87 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.87 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.985 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} diff --git a/resources/js/app.tsx b/resources/js/app.tsx new file mode 100644 index 00000000..5266d829 --- /dev/null +++ b/resources/js/app.tsx @@ -0,0 +1,45 @@ +import { createInertiaApp } from "@inertiajs/react"; +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { initializeTheme } from "@/hooks/use-appearance"; +import AppLayout from "@/layouts/app-layout"; +import AuthLayout from "@/layouts/auth-layout"; +import SettingsLayout from "@/layouts/settings/layout"; +import PortalNavigation from "@/layouts/portal-navigation"; + +const appName = import.meta.env.VITE_APP_NAME || "Laravel"; + +void createInertiaApp({ + title: (title) => (title ? `${title} - ${appName}` : appName), + layout: (name) => { + switch (true) { + case name === "welcome": + case name === "PortalInvestidor": + case name === "management-access": + case name.startsWith("portal/"): + case name.startsWith("investment/"): + return PortalNavigation; + case name.startsWith("auth/"): + return [PortalNavigation, AuthLayout]; + case name.startsWith("settings/"): + return [PortalNavigation, AppLayout, SettingsLayout]; + default: + return [PortalNavigation, AppLayout]; + } + }, + strictMode: true, + withApp(app) { + return ( + + {app} + + + ); + }, + progress: { + color: "#4B5563", + }, +}); + +// This will set light / dark mode on load... +initializeTheme(); diff --git a/resources/js/components/alert-error.tsx b/resources/js/components/alert-error.tsx new file mode 100644 index 00000000..a9eacd31 --- /dev/null +++ b/resources/js/components/alert-error.tsx @@ -0,0 +1,24 @@ +import { AlertCircleIcon } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; + +export default function AlertError({ + errors, + title, +}: { + errors: string[]; + title?: string; +}) { + return ( + + + {title || 'Something went wrong.'} + +
    + {Array.from(new Set(errors)).map((error, index) => ( +
  • {error}
  • + ))} +
+
+
+ ); +} diff --git a/resources/js/components/app-content.tsx b/resources/js/components/app-content.tsx new file mode 100644 index 00000000..5a04c696 --- /dev/null +++ b/resources/js/components/app-content.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { SidebarInset } from '@/components/ui/sidebar'; +import type { AppVariant } from '@/types'; + +type Props = React.ComponentProps<'main'> & { + variant?: AppVariant; +}; + +export function AppContent({ variant = 'sidebar', children, ...props }: Props) { + if (variant === 'sidebar') { + return {children}; + } + + return ( +
+ {children} +
+ ); +} diff --git a/resources/js/components/app-header.tsx b/resources/js/components/app-header.tsx new file mode 100644 index 00000000..93382053 --- /dev/null +++ b/resources/js/components/app-header.tsx @@ -0,0 +1,248 @@ +import { Link, usePage } from '@inertiajs/react'; +import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react'; +import AppLogo from '@/components/app-logo'; +import AppLogoIcon from '@/components/app-logo-icon'; +import { Breadcrumbs } from '@/components/breadcrumbs'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + NavigationMenu, + NavigationMenuItem, + NavigationMenuList, + navigationMenuTriggerStyle, +} from '@/components/ui/navigation-menu'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@/components/ui/sheet'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { UserMenuContent } from '@/components/user-menu-content'; +import { useCurrentUrl } from '@/hooks/use-current-url'; +import { useInitials } from '@/hooks/use-initials'; +import { cn, toUrl } from '@/lib/utils'; +import { dashboard } from '@/routes'; +import type { BreadcrumbItem, NavItem } from '@/types'; + +type Props = { + breadcrumbs?: BreadcrumbItem[]; +}; + +const mainNavItems: NavItem[] = [ + { + title: 'Dashboard', + href: dashboard(), + icon: LayoutGrid, + }, +]; + +const rightNavItems: NavItem[] = [ + { + title: 'Repository', + href: 'https://github.com/laravel/react-starter-kit', + icon: Folder, + }, + { + title: 'Documentation', + href: 'https://laravel.com/docs/starter-kits#react', + icon: BookOpen, + }, +]; + +const activeItemStyles = + 'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'; + +export function AppHeader({ breadcrumbs = [] }: Props) { + const page = usePage(); + const { auth } = page.props; + const getInitials = useInitials(); + const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl(); + + return ( + <> +
+
+ {/* Mobile Menu */} +
+ + + + + + + Navigation menu + + + + +
+
+
+ {mainNavItems.map((item) => ( + + {item.icon && ( + + )} + {item.title} + + ))} +
+ +
+ {rightNavItems.map((item) => ( + + {item.icon && ( + + )} + {item.title} + + ))} +
+
+
+
+
+
+ + + + + + {/* Desktop Navigation */} +
+ + + {mainNavItems.map((item, index) => ( + + + {item.icon && ( + + )} + {item.title} + + {isCurrentUrl(item.href) && ( +
+ )} +
+ ))} +
+
+
+ +
+
+ +
+ {rightNavItems.map((item) => ( + + + + + {item.title} + + {item.icon && ( + + )} + + + +

{item.title}

+
+
+ ))} +
+
+ + + + + + {auth.user && ( + + )} + + +
+
+
+ {breadcrumbs.length > 1 && ( +
+
+ +
+
+ )} + + ); +} diff --git a/resources/js/components/app-logo-icon.tsx b/resources/js/components/app-logo-icon.tsx new file mode 100644 index 00000000..0c312153 --- /dev/null +++ b/resources/js/components/app-logo-icon.tsx @@ -0,0 +1,13 @@ +import type { SVGAttributes } from 'react'; + +export default function AppLogoIcon(props: SVGAttributes) { + return ( + + + + ); +} diff --git a/resources/js/components/app-logo.tsx b/resources/js/components/app-logo.tsx new file mode 100644 index 00000000..4845632e --- /dev/null +++ b/resources/js/components/app-logo.tsx @@ -0,0 +1,20 @@ +import { usePage } from '@inertiajs/react'; + +import AppLogoIcon from '@/components/app-logo-icon'; + +export default function AppLogo() { + const { name } = usePage().props; + + return ( + <> +
+ +
+
+ + {name} + +
+ + ); +} diff --git a/resources/js/components/app-shell.tsx b/resources/js/components/app-shell.tsx new file mode 100644 index 00000000..f51a9dbf --- /dev/null +++ b/resources/js/components/app-shell.tsx @@ -0,0 +1,21 @@ +import { usePage } from '@inertiajs/react'; +import type { ReactNode } from 'react'; +import { SidebarProvider } from '@/components/ui/sidebar'; +import type { AppVariant } from '@/types'; + +type Props = { + children: ReactNode; + variant?: AppVariant; +}; + +export function AppShell({ children, variant = 'sidebar' }: Props) { + const isOpen = usePage().props.sidebarOpen; + + if (variant === 'header') { + return ( +
{children}
+ ); + } + + return {children}; +} diff --git a/resources/js/components/app-sidebar-header.tsx b/resources/js/components/app-sidebar-header.tsx new file mode 100644 index 00000000..11c447b4 --- /dev/null +++ b/resources/js/components/app-sidebar-header.tsx @@ -0,0 +1,18 @@ +import { Breadcrumbs } from '@/components/breadcrumbs'; +import { SidebarTrigger } from '@/components/ui/sidebar'; +import type { BreadcrumbItem as BreadcrumbItemType } from '@/types'; + +export function AppSidebarHeader({ + breadcrumbs = [], +}: { + breadcrumbs?: BreadcrumbItemType[]; +}) { + return ( +
+
+ + +
+
+ ); +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx new file mode 100644 index 00000000..3b787cde --- /dev/null +++ b/resources/js/components/app-sidebar.tsx @@ -0,0 +1,99 @@ +import { Link, usePage } from '@inertiajs/react'; +import { BookOpen, FolderGit2, LayoutGrid } from 'lucide-react'; +import AppLogo from '@/components/app-logo'; +import { NavFooter } from '@/components/nav-footer'; +import { NavMain } from '@/components/nav-main'; +import { NavUser } from '@/components/nav-user'; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from '@/components/ui/sidebar'; +import { dashboard } from '@/routes'; +import type { NavItem, Auth } from '@/types'; + +const mainNavItems: NavItem[] = [ + { title: 'Painel do investidor', href: '/investidor', icon: LayoutGrid }, + { title: 'Corredores e clusters', href: '/carteira', icon: LayoutGrid }, + { + title: 'Dashboard', + href: dashboard(), + icon: LayoutGrid, + }, +]; + +const footerNavItems: NavItem[] = [ + { + title: 'Repository', + href: 'https://github.com/laravel/react-starter-kit', + icon: FolderGit2, + }, + { + title: 'Documentation', + href: 'https://laravel.com/docs/starter-kits#react', + icon: BookOpen, + }, +]; + +export function AppSidebar() { + const { auth } = usePage<{ auth: Auth }>().props; + const items = auth.canManagePortal + ? [ + ...mainNavItems, + { + title: 'Pedidos de interesse', + href: '/management/interests', + icon: LayoutGrid, + }, + { + title: 'Sectores', + href: '/management/sectors', + icon: LayoutGrid, + }, + { + title: 'Oportunidades', + href: '/management/opportunities', + icon: LayoutGrid, + }, + { title: 'CRM', href: '/management/crm/leads', icon: LayoutGrid }, + { + title: 'Projectos', + href: '/management/projects', + icon: LayoutGrid, + }, + { + title: 'Clusters', + href: '/management/clusters', + icon: LayoutGrid, + }, + ] + : mainNavItems; + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/resources/js/components/appearance-tabs.tsx b/resources/js/components/appearance-tabs.tsx new file mode 100644 index 00000000..b0138624 --- /dev/null +++ b/resources/js/components/appearance-tabs.tsx @@ -0,0 +1,45 @@ +import type { LucideIcon } from 'lucide-react'; +import { Monitor, Moon, Sun } from 'lucide-react'; +import type { HTMLAttributes } from 'react'; +import type { Appearance } from '@/hooks/use-appearance'; +import { useAppearance } from '@/hooks/use-appearance'; +import { cn } from '@/lib/utils'; + +export default function AppearanceToggleTab({ + className = '', + ...props +}: HTMLAttributes) { + const { appearance, updateAppearance } = useAppearance(); + + const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [ + { value: 'light', icon: Sun, label: 'Light' }, + { value: 'dark', icon: Moon, label: 'Dark' }, + { value: 'system', icon: Monitor, label: 'System' }, + ]; + + return ( +
+ {tabs.map(({ value, icon: Icon, label }) => ( + + ))} +
+ ); +} diff --git a/resources/js/components/breadcrumbs.tsx b/resources/js/components/breadcrumbs.tsx new file mode 100644 index 00000000..3420084e --- /dev/null +++ b/resources/js/components/breadcrumbs.tsx @@ -0,0 +1,50 @@ +import { Link } from '@inertiajs/react'; +import { Fragment } from 'react'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import type { BreadcrumbItem as BreadcrumbItemType } from '@/types'; + +export function Breadcrumbs({ + breadcrumbs, +}: { + breadcrumbs: BreadcrumbItemType[]; +}) { + return ( + <> + {breadcrumbs.length > 0 && ( + + + {breadcrumbs.map((item, index) => { + const isLast = index === breadcrumbs.length - 1; + + return ( + + + {isLast ? ( + + {item.title} + + ) : ( + + + {item.title} + + + )} + + {!isLast && } + + ); + })} + + + )} + + ); +} diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx new file mode 100644 index 00000000..213e76f1 --- /dev/null +++ b/resources/js/components/delete-user.tsx @@ -0,0 +1,120 @@ +import { Form } from '@inertiajs/react'; +import { useRef } from 'react'; +import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; +import Heading from '@/components/heading'; +import InputError from '@/components/input-error'; +import PasswordInput from '@/components/password-input'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; + +export default function DeleteUser() { + const passwordInput = useRef(null); + + return ( +
+ +
+
+

Warning

+

+ Please proceed with caution, this cannot be undone. +

+
+ + + + + + + + Are you sure you want to delete your account? + + + Once your account is deleted, all of its resources + and data will also be permanently deleted. Please + enter your password to confirm you would like to + permanently delete your account. + + +
passwordInput.current?.focus()} + resetOnSuccess + className="space-y-6" + > + {({ resetAndClearErrors, processing, errors }) => ( + <> +
+ + + + + +
+ + + + + + + + + + + )} +
+
+
+
+
+ ); +} diff --git a/resources/js/components/heading.tsx b/resources/js/components/heading.tsx new file mode 100644 index 00000000..5170403f --- /dev/null +++ b/resources/js/components/heading.tsx @@ -0,0 +1,26 @@ +export default function Heading({ + title, + description, + variant = 'default', +}: { + title: string; + description?: string; + variant?: 'default' | 'small'; +}) { + return ( +
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ ); +} diff --git a/resources/js/components/input-error.tsx b/resources/js/components/input-error.tsx new file mode 100644 index 00000000..f323cee9 --- /dev/null +++ b/resources/js/components/input-error.tsx @@ -0,0 +1,17 @@ +import type { HTMLAttributes } from 'react'; +import { cn } from '@/lib/utils'; + +export default function InputError({ + message, + className = '', + ...props +}: HTMLAttributes & { message?: string }) { + return message ? ( +

+ {message} +

+ ) : null; +} diff --git a/resources/js/components/manage-passkeys.tsx b/resources/js/components/manage-passkeys.tsx new file mode 100644 index 00000000..41629595 --- /dev/null +++ b/resources/js/components/manage-passkeys.tsx @@ -0,0 +1,71 @@ +import { router } from '@inertiajs/react'; +import { KeyRound } from 'lucide-react'; +import { destroy } from '@/actions/Laravel/Passkeys/Http/Controllers/PasskeyRegistrationController'; +import Heading from '@/components/heading'; +import PasskeyItem from '@/components/passkey-item'; +import PasskeyRegistration from '@/components/passkey-register'; +import type { Passkey } from '@/types/auth'; + +export type Props = { + canManagePasskeys?: boolean; + passkeys?: Passkey[]; +}; + +const EmptyState = () => { + return ( +
+
+ +
+

No passkeys yet

+

+ Add a passkey to sign in without a password +

+
+ ); +}; + +export default function ManagePasskeys(props: Props) { + const passkeys = props.passkeys ?? []; + + const handleDelete = (id: number, onError: () => void) => { + router.delete(destroy.url(id), { + preserveScroll: true, + onError, + }); + }; + + const handleRegisterSuccess = () => { + router.reload(); + }; + + if (!(props.canManagePasskeys ?? false)) { + return null; + } + + return ( +
+ + +
+ {passkeys.length > 0 ? ( + passkeys.map((passkey) => ( + + )) + ) : ( + + )} +
+ + +
+ ); +} diff --git a/resources/js/components/manage-two-factor.tsx b/resources/js/components/manage-two-factor.tsx new file mode 100644 index 00000000..9fb5370a --- /dev/null +++ b/resources/js/components/manage-two-factor.tsx @@ -0,0 +1,126 @@ +import { Form } from '@inertiajs/react'; +import { ShieldCheck } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import Heading from '@/components/heading'; +import TwoFactorRecoveryCodes from '@/components/two-factor-recovery-codes'; +import TwoFactorSetupModal from '@/components/two-factor-setup-modal'; +import { Button } from '@/components/ui/button'; +import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth'; +import { disable, enable } from '@/routes/two-factor'; + +export type Props = { + canManageTwoFactor?: boolean; + requiresConfirmation?: boolean; + twoFactorEnabled?: boolean; +}; + +export default function ManageTwoFactor(props: Props) { + const requiresConfirmation = props.requiresConfirmation ?? false; + const twoFactorEnabled = props.twoFactorEnabled ?? false; + + const { + qrCodeSvg, + hasSetupData, + manualSetupKey, + clearSetupData, + clearTwoFactorAuthData, + fetchSetupData, + recoveryCodesList, + fetchRecoveryCodes, + errors, + } = useTwoFactorAuth(); + const [showSetupModal, setShowSetupModal] = useState(false); + const prevTwoFactorEnabled = useRef(twoFactorEnabled); + + useEffect(() => { + if (prevTwoFactorEnabled.current && !twoFactorEnabled) { + clearTwoFactorAuthData(); + } + + prevTwoFactorEnabled.current = twoFactorEnabled; + }, [twoFactorEnabled, clearTwoFactorAuthData]); + + if (!(props.canManageTwoFactor ?? false)) { + return null; + } + + return ( +
+ + {twoFactorEnabled ? ( +
+

+ You will be prompted for a secure, random pin during + login, which you can retrieve from the TOTP-supported + application on your phone. +

+ +
+
+ {({ processing }) => ( + + )} +
+
+ + +
+ ) : ( +
+

+ When you enable two-factor authentication, you will be + prompted for a secure pin during login. This pin can be + retrieved from a TOTP-supported application on your + phone. +

+ +
+ {hasSetupData ? ( + + ) : ( +
setShowSetupModal(true)} + > + {({ processing }) => ( + + )} +
+ )} +
+
+ )} + + setShowSetupModal(false)} + requiresConfirmation={requiresConfirmation} + twoFactorEnabled={twoFactorEnabled} + qrCodeSvg={qrCodeSvg} + manualSetupKey={manualSetupKey} + clearSetupData={clearSetupData} + fetchSetupData={fetchSetupData} + errors={errors} + /> +
+ ); +} diff --git a/resources/js/components/nav-footer.tsx b/resources/js/components/nav-footer.tsx new file mode 100644 index 00000000..8430e495 --- /dev/null +++ b/resources/js/components/nav-footer.tsx @@ -0,0 +1,49 @@ +import type { ComponentPropsWithoutRef } from 'react'; +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from '@/components/ui/sidebar'; +import { toUrl } from '@/lib/utils'; +import type { NavItem } from '@/types'; + +export function NavFooter({ + items, + className, + ...props +}: ComponentPropsWithoutRef & { + items: NavItem[]; +}) { + return ( + + + + {items.map((item) => ( + + + + {item.icon && ( + + )} + {item.title} + + + + ))} + + + + ); +} diff --git a/resources/js/components/nav-main.tsx b/resources/js/components/nav-main.tsx new file mode 100644 index 00000000..62fcc6fa --- /dev/null +++ b/resources/js/components/nav-main.tsx @@ -0,0 +1,36 @@ +import { Link } from '@inertiajs/react'; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from '@/components/ui/sidebar'; +import { useCurrentUrl } from '@/hooks/use-current-url'; +import type { NavItem } from '@/types'; + +export function NavMain({ items }: { items: NavItem[] }) { + const { isCurrentUrl } = useCurrentUrl(); + + return ( + + Platform + + {items.map((item) => ( + + + + {item.icon && } + {item.title} + + + + ))} + + + ); +} diff --git a/resources/js/components/nav-user.tsx b/resources/js/components/nav-user.tsx new file mode 100644 index 00000000..a4072397 --- /dev/null +++ b/resources/js/components/nav-user.tsx @@ -0,0 +1,58 @@ +import { usePage } from '@inertiajs/react'; +import { ChevronsUpDown } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from '@/components/ui/sidebar'; +import { UserInfo } from '@/components/user-info'; +import { UserMenuContent } from '@/components/user-menu-content'; +import { useIsMobile } from '@/hooks/use-mobile'; + +export function NavUser() { + const { auth } = usePage().props; + const { state } = useSidebar(); + const isMobile = useIsMobile(); + + if (!auth.user) { + return null; + } + + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/resources/js/components/passkey-item.tsx b/resources/js/components/passkey-item.tsx new file mode 100644 index 00000000..d1918603 --- /dev/null +++ b/resources/js/components/passkey-item.tsx @@ -0,0 +1,93 @@ +import { KeyRound, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import type { Passkey } from '@/types/auth'; + +type Props = { + passkey: Passkey; + onDelete: (id: number, onError: () => void) => void; +}; + +export default function PasskeyItem({ passkey, onDelete }: Props) { + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = () => { + setIsDeleting(true); + onDelete(passkey.id, () => setIsDeleting(false)); + }; + + return ( +
+
+
+ +
+
+
+

+ {passkey.name} +

+ {passkey.authenticator && ( + + {passkey.authenticator} + + )} +
+

+ Added {passkey.created_at_diff} + {passkey.last_used_at_diff && ( + <> + + / + + Last used {passkey.last_used_at_diff} + + )} +

+
+
+ + + + + + + Remove passkey + + Are you sure you want to remove the "{passkey.name}" + passkey? You will no longer be able to use it to sign + in. + + + + + + + + + +
+ ); +} diff --git a/resources/js/components/passkey-register.tsx b/resources/js/components/passkey-register.tsx new file mode 100644 index 00000000..a4d2a5e9 --- /dev/null +++ b/resources/js/components/passkey-register.tsx @@ -0,0 +1,108 @@ +import { usePasskeyRegister } from '@laravel/passkeys/react'; +import { useState } from 'react'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +type Props = { + onSuccess: () => void; +}; + +export default function PasskeyRegistration({ onSuccess }: Props) { + const [name, setName] = useState(() => { + const ua = navigator.userAgent; + + const browser = [ + { pattern: /Edg|Edge/, name: 'Edge' }, + { pattern: /OPR|Opera|OPiOS/, name: 'Opera' }, + { pattern: /Firefox|FxiOS/, name: 'Firefox' }, + { pattern: /Chrome|CriOS/, name: 'Chrome' }, + { pattern: /Safari/, name: 'Safari' }, + ].find(({ pattern }) => pattern.test(ua))?.name; + + const os = [ + { pattern: /iPhone/, name: 'iPhone' }, + { pattern: /iPad|Macintosh(?=.*Mobile)/, name: 'iPad' }, + { pattern: /Android/, name: 'Android' }, + { pattern: /Mac/, name: 'Mac' }, + { pattern: /Windows/, name: 'Windows' }, + ].find(({ pattern }) => pattern.test(ua))?.name; + + return [browser, os].filter(Boolean).join(' on ') || ''; + }); + + const [showForm, setShowForm] = useState(false); + const { register, isLoading, error, isSupported } = usePasskeyRegister({ + onSuccess: () => { + setName(''); + setShowForm(false); + onSuccess(); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!name.trim()) { + return; + } + + await register(name); + }; + + const handleCancel = () => { + setShowForm(false); + setName(''); + }; + + if (!isSupported) { + return ( +
+ Passkeys are not supported in this browser. +
+ ); + } + + if (!showForm) { + return ( + + ); + } + + return ( +
+
+ + setName(e.target.value)} + placeholder="e.g., MacBook Pro, iPhone" + className="border-foreground/20 mt-1 block w-full" + autoFocus + /> +

+ A name helps you identify this passkey later. +

+
+ + {error && } + +
+ + +
+ + ); +} diff --git a/resources/js/components/passkey-verify.tsx b/resources/js/components/passkey-verify.tsx new file mode 100644 index 00000000..adb714f1 --- /dev/null +++ b/resources/js/components/passkey-verify.tsx @@ -0,0 +1,74 @@ +import type { UrlMethodPair } from '@inertiajs/core'; +import { router } from '@inertiajs/react'; +import { usePasskeyVerify } from '@laravel/passkeys/react'; +import { KeyRound } from 'lucide-react'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { Spinner } from '@/components/ui/spinner'; + +type Props = { + routes?: { + options: UrlMethodPair; + submit: UrlMethodPair; + }; + label?: string; + loadingLabel?: string; + separator?: string; +}; + +export default function PasskeyVerify({ + routes, + label, + loadingLabel, + separator, +}: Props = {}) { + const { verify, isLoading, error, isSupported } = usePasskeyVerify({ + ...(routes && { + routes: { + options: routes.options.url, + submit: routes.submit.url, + }, + }), + onSuccess: (response) => { + router.visit(response.redirect ?? '/dashboard'); + }, + }); + + if (!isSupported) { + return null; + } + + return ( + <> +
+ + {error && ( + + )} +
+ +
+
+ +
+
+ + {separator ?? 'Or continue with email'} + +
+
+ + ); +} diff --git a/resources/js/components/password-input.tsx b/resources/js/components/password-input.tsx new file mode 100644 index 00000000..cbc0087e --- /dev/null +++ b/resources/js/components/password-input.tsx @@ -0,0 +1,37 @@ +import { Eye, EyeOff } from 'lucide-react'; +import type { ComponentProps, Ref } from 'react'; +import { useState } from 'react'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; + +export default function PasswordInput({ + className, + ref, + ...props +}: Omit, 'type'> & { ref?: Ref }) { + const [showPassword, setShowPassword] = useState(false); + + return ( +
+ + +
+ ); +} diff --git a/resources/js/components/text-link.tsx b/resources/js/components/text-link.tsx new file mode 100644 index 00000000..08ad2694 --- /dev/null +++ b/resources/js/components/text-link.tsx @@ -0,0 +1,23 @@ +import { Link } from '@inertiajs/react'; +import type { ComponentProps } from 'react'; +import { cn } from '@/lib/utils'; + +type Props = ComponentProps; + +export default function TextLink({ + className = '', + children, + ...props +}: Props) { + return ( + + {children} + + ); +} diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx new file mode 100644 index 00000000..df51ca22 --- /dev/null +++ b/resources/js/components/two-factor-recovery-codes.tsx @@ -0,0 +1,164 @@ +import { Form } from '@inertiajs/react'; +import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import AlertError from '@/components/alert-error'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { regenerateRecoveryCodes } from '@/routes/two-factor'; + +type Props = { + recoveryCodesList: string[]; + fetchRecoveryCodes: () => Promise; + errors: string[]; +}; + +export default function TwoFactorRecoveryCodes({ + recoveryCodesList, + fetchRecoveryCodes, + errors, +}: Props) { + const [codesAreVisible, setCodesAreVisible] = useState(false); + const codesSectionRef = useRef(null); + const canRegenerateCodes = recoveryCodesList.length > 0 && codesAreVisible; + + const toggleCodesVisibility = useCallback(async () => { + if (!codesAreVisible && !recoveryCodesList.length) { + await fetchRecoveryCodes(); + } + + setCodesAreVisible(!codesAreVisible); + + if (!codesAreVisible) { + setTimeout(() => { + codesSectionRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + }); + } + }, [codesAreVisible, recoveryCodesList.length, fetchRecoveryCodes]); + + useEffect(() => { + if (!recoveryCodesList.length) { + void fetchRecoveryCodes(); + } + }, [recoveryCodesList.length, fetchRecoveryCodes]); + + const RecoveryCodeIconComponent = codesAreVisible ? EyeOff : Eye; + + return ( + + + + + + Recovery codes let you regain access if you lose your 2FA + device. Store them in a secure password manager. + + + +
+ + + {canRegenerateCodes && ( +
+ {({ processing }) => ( + + )} +
+ )} +
+
+
+ {errors?.length ? ( + + ) : ( + <> +
+ {recoveryCodesList.length ? ( + recoveryCodesList.map((code, index) => ( +
+ {code} +
+ )) + ) : ( +
+ {Array.from( + { length: 8 }, + (_, index) => ( + + )} +
+ +
+

+ Each recovery code can be used once to + access your account and will be removed + after use. If you need more, click{' '} + + Regenerate codes + {' '} + above. +

+
+ + )} +
+
+ + + ); +} diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx new file mode 100644 index 00000000..903a7894 --- /dev/null +++ b/resources/js/components/two-factor-setup-modal.tsx @@ -0,0 +1,355 @@ +import { Form } from '@inertiajs/react'; +import { REGEXP_ONLY_DIGITS } from 'input-otp'; +import { Check, Copy, ScanLine } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import AlertError from '@/components/alert-error'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from '@/components/ui/input-otp'; +import { Spinner } from '@/components/ui/spinner'; +import { useAppearance } from '@/hooks/use-appearance'; +import { useClipboard } from '@/hooks/use-clipboard'; +import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth'; +import { confirm } from '@/routes/two-factor'; + +function GridScanIcon() { + return ( +
+
+
+ {Array.from({ length: 5 }, (_, i) => ( +
+ ))} +
+
+ {Array.from({ length: 5 }, (_, i) => ( +
+ ))} +
+ +
+
+ ); +} + +function TwoFactorSetupStep({ + qrCodeSvg, + manualSetupKey, + buttonText, + onNextStep, + errors, +}: { + qrCodeSvg: string | null; + manualSetupKey: string | null; + buttonText: string; + onNextStep: () => void; + errors: string[]; +}) { + const { resolvedAppearance } = useAppearance(); + const [copiedText, copy] = useClipboard(); + const IconComponent = copiedText === manualSetupKey ? Check : Copy; + + return ( + <> + {errors?.length ? ( + + ) : ( + <> +
+
+
+ {qrCodeSvg ? ( +
+ ) : ( + + )} +
+
+
+ +
+ +
+ +
+
+ + or, enter the code manually + +
+ +
+
+ {!manualSetupKey ? ( +
+ +
+ ) : ( + <> + + + + )} +
+
+ + )} + + ); +} + +function TwoFactorVerificationStep({ + onClose, + onBack, +}: { + onClose: () => void; + onBack: () => void; +}) { + const [code, setCode] = useState(''); + const pinInputContainerRef = useRef(null); + + useEffect(() => { + setTimeout(() => { + pinInputContainerRef.current?.querySelector('input')?.focus(); + }, 0); + }, []); + + return ( +
onClose()} + resetOnError + resetOnSuccess + > + {({ + processing, + errors, + }: { + processing: boolean; + errors?: { confirmTwoFactorAuthentication?: { code?: string } }; + }) => ( + <> +
+
+ + + {Array.from( + { length: OTP_MAX_LENGTH }, + (_, index) => ( + + ), + )} + + + +
+ +
+ + +
+
+ + )} +
+ ); +} + +type Props = { + isOpen: boolean; + onClose: () => void; + requiresConfirmation: boolean; + twoFactorEnabled: boolean; + qrCodeSvg: string | null; + manualSetupKey: string | null; + clearSetupData: () => void; + fetchSetupData: () => Promise; + errors: string[]; +}; + +export default function TwoFactorSetupModal({ + isOpen, + onClose, + requiresConfirmation, + twoFactorEnabled, + qrCodeSvg, + manualSetupKey, + clearSetupData, + fetchSetupData, + errors, +}: Props) { + const [showVerificationStep, setShowVerificationStep] = + useState(false); + + const modalConfig = useMemo<{ + title: string; + description: string; + buttonText: string; + }>(() => { + if (twoFactorEnabled) { + return { + title: 'Two-factor authentication enabled', + description: + 'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.', + buttonText: 'Close', + }; + } + + if (showVerificationStep) { + return { + title: 'Verify authentication code', + description: + 'Enter the 6-digit code from your authenticator app', + buttonText: 'Continue', + }; + } + + return { + title: 'Enable two-factor authentication', + description: + 'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app', + buttonText: 'Continue', + }; + }, [twoFactorEnabled, showVerificationStep]); + + const resetModalState = useCallback(() => { + if (twoFactorEnabled) { + clearSetupData(); + } + + setShowVerificationStep(false); + }, [clearSetupData, twoFactorEnabled]); + + const handleClose = useCallback(() => { + resetModalState(); + onClose(); + }, [onClose, resetModalState]); + + const handleModalNextStep = useCallback(() => { + if (requiresConfirmation) { + setShowVerificationStep(true); + + return; + } + + clearSetupData(); + handleClose(); + }, [requiresConfirmation, clearSetupData, handleClose]); + + const fetchSetupDataRef = useRef(fetchSetupData); + + useEffect(() => { + fetchSetupDataRef.current = fetchSetupData; + }, [fetchSetupData]); + + useEffect(() => { + if (isOpen && !qrCodeSvg) { + void fetchSetupDataRef.current(); + } + }, [isOpen, qrCodeSvg]); + + return ( + !open && handleClose()}> + + + + {modalConfig.title} + + {modalConfig.description} + + + +
+ {showVerificationStep ? ( + setShowVerificationStep(false)} + /> + ) : ( + + )} +
+
+
+ ); +} diff --git a/resources/js/components/ui/alert.tsx b/resources/js/components/ui/alert.tsx new file mode 100644 index 00000000..6e6727ad --- /dev/null +++ b/resources/js/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "text-destructive-foreground [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-foreground/80", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/resources/js/components/ui/avatar.tsx b/resources/js/components/ui/avatar.tsx new file mode 100644 index 00000000..5a6986a2 --- /dev/null +++ b/resources/js/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as AvatarPrimitive from "@radix-ui/react-avatar" +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/resources/js/components/ui/badge.tsx b/resources/js/components/ui/badge.tsx new file mode 100644 index 00000000..58294afe --- /dev/null +++ b/resources/js/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/resources/js/components/ui/breadcrumb.tsx b/resources/js/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..12a631e9 --- /dev/null +++ b/resources/js/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return + ); +} diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx new file mode 100644 index 00000000..efd79042 --- /dev/null +++ b/resources/js/layouts/settings/layout.tsx @@ -0,0 +1,78 @@ +import { Link } from '@inertiajs/react'; +import type { PropsWithChildren } from 'react'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { useCurrentUrl } from '@/hooks/use-current-url'; +import { cn, toUrl } from '@/lib/utils'; +import { edit as editAppearance } from '@/routes/appearance'; +import { edit } from '@/routes/profile'; +import { edit as editSecurity } from '@/routes/security'; +import type { NavItem } from '@/types'; + +const sidebarNavItems: NavItem[] = [ + { + title: 'Profile', + href: edit(), + icon: null, + }, + { + title: 'Security', + href: editSecurity(), + icon: null, + }, + { + title: 'Appearance', + href: editAppearance(), + icon: null, + }, +]; + +export default function SettingsLayout({ children }: PropsWithChildren) { + const { isCurrentOrParentUrl } = useCurrentUrl(); + + return ( +
+ + +
+ + + + +
+
+ {children} +
+
+
+
+ ); +} diff --git a/resources/js/lib/portal-api.ts b/resources/js/lib/portal-api.ts new file mode 100644 index 00000000..61df87b5 --- /dev/null +++ b/resources/js/lib/portal-api.ts @@ -0,0 +1,227 @@ +export type Sector = { + slug: string; + name: string; + shortDescription: string | null; + opportunitiesCount: number; +}; + +export type Opportunity = { + slug: string; + reference: string; + title: string; + sectorName: string; + province: string | null; + maturity: string | null; + amount: string | null; + currency: string; + financing: string | null; + impact: string | null; + summary: string | null; + description: string | null; + featured: boolean; +}; + +export type OpportunityRecord = { + slug: string; + reference: string; + title: string; + sector: { name: string; slug: string } | null; + province: string | null; + maturity_label: string | null; + estimated_investment_amount: string | null; + estimated_investment_currency: string; + financing_modality: string | null; + expected_impact: string | null; + summary: string | null; + description: string | null; + is_featured: boolean; +}; + +export type HomeResponse = { + success: boolean; + data: { + sectors: Sector[]; + stats: { opportunities: number; sectors: number; provinces: number }; + featuredOpportunities: { + reference: string; + title: string; + sector: string | null; + province: string | null; + maturity: string | null; + estimatedInvestment: { amount: string | null; currency: string }; + url: string; + }[]; + }; +}; + +export type SearchResponse = { + data: OpportunityRecord[]; + current_page: number; + last_page: number; + total: number; +}; + +export type Filters = { + minAmount?: string; + maxAmount?: string; + currency?: string; + query: string; + sector: string; + province: string; + maturity: string; +}; + +export type InterestFormData = { + name: string; + email: string; + phone: string; + investorType: string; + country: string; + sectorInterest: string; + opportunityReference: string; + investmentRange: string; + message: string; + privacyConsent: boolean; +}; + +export type FormErrors = Partial>; + +export class PortalApiError extends Error { + status: number; + errors: Record; + + constructor(status: number, errors: Record = {}) { + super( + status === 429 + ? "Foram enviados demasiados pedidos. Aguarde um minuto e tente novamente." + : status === 404 + ? "O registo solicitado já não está disponível." + : status === 422 + ? "Verifique os campos assinalados." + : "Não foi possível comunicar com o portal. Tente novamente.", + ); + this.status = status; + this.errors = errors; + } +} + +export async function portalRequest(url: string, options: RequestInit = {}): Promise { + const headers = new Headers(options.headers); + headers.set("Accept", "application/json"); + const response = await fetch(url, { + ...options, + credentials: "same-origin", + headers, + }); + const body = await response.json().catch(() => null); + if (!response.ok || body === null) { + throw new PortalApiError( + response.status, + response.status === 422 ? body?.errors : undefined, + ); + } + return body as T; +} + +export function toOpportunity(record: OpportunityRecord): Opportunity { + return { + slug: record.slug, + reference: record.reference, + title: record.title, + sectorName: record.sector?.name ?? "Sector não indicado", + province: record.province, + maturity: record.maturity_label, + amount: record.estimated_investment_amount, + currency: record.estimated_investment_currency, + financing: record.financing_modality, + impact: record.expected_impact, + summary: record.summary, + description: record.description, + featured: record.is_featured, + }; +} + +export function toFeaturedOpportunity( + record: HomeResponse["data"]["featuredOpportunities"][number], +): Opportunity { + return { + slug: record.url.split("/").filter(Boolean).at(-1) ?? "", + reference: record.reference, + title: record.title, + sectorName: record.sector ?? "Sector não indicado", + province: record.province, + maturity: record.maturity, + amount: record.estimatedInvestment.amount, + currency: record.estimatedInvestment.currency, + financing: null, + impact: null, + summary: null, + description: null, + featured: true, + }; +} + +export function searchUrl(filters: Filters, page: number): string { + const params = new URLSearchParams({ page: String(page), per_page: "12" }); + if (filters.query.trim()) params.set("q", filters.query.trim()); + if (filters.sector) params.set("sector", filters.sector); + if (filters.province) params.set("province", filters.province); + if (filters.maturity) params.set("maturity_level", filters.maturity); + if (filters.minAmount) params.set("min_amount", filters.minAmount); + if (filters.maxAmount) params.set("max_amount", filters.maxAmount); + if (filters.currency) params.set("currency", filters.currency); + return `/api/v1/opportunities/search?${params.toString()}`; +} + +export function interestPayload(form: InterestFormData) { + const { name, ...fields } = form; + return { ...fields, fullName: name.trim() }; +} + +export function newSubmissionKey(): string { + // getRandomValues also supports local HTTP development origins. + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export function fieldErrors(error: PortalApiError): FormErrors { + const result: FormErrors = {}; + const fields: (keyof InterestFormData)[] = [ + "name", + "email", + "phone", + "investorType", + "country", + "sectorInterest", + "opportunityReference", + "investmentRange", + "message", + "privacyConsent", + ]; + for (const field of fields) { + const messages = error.errors[field === "name" ? "fullName" : field]; + if (Array.isArray(messages) && typeof messages[0] === "string") { + result[field] = messages[0]; + } + } + return result; +} + +export function money(amount: string | null, currency: string): string { + if (amount === null || !/^-?\d+(\.\d+)?$/.test(amount)) return "Por indicar"; + try { + // Intl accepts decimal strings without converting them to a lossy Number. + const formatter = new Intl.NumberFormat("pt-MZ", { + style: "currency", + currency, + maximumFractionDigits: 2, + }); + // TypeScript's declaration omits the decimal-string input supported by Intl. + return formatter.format(amount as unknown as number); + } catch { + return `${amount} ${currency}`; + } +} diff --git a/resources/js/lib/utils.ts b/resources/js/lib/utils.ts new file mode 100644 index 00000000..3ead67fc --- /dev/null +++ b/resources/js/lib/utils.ts @@ -0,0 +1,12 @@ +import type { InertiaLinkProps } from '@inertiajs/react'; +import { clsx } from 'clsx'; +import type { ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function toUrl(url: NonNullable): string { + return typeof url === 'string' ? url : url.url; +} diff --git a/resources/js/pages/PortalInvestidor.tsx b/resources/js/pages/PortalInvestidor.tsx new file mode 100644 index 00000000..f5602a28 --- /dev/null +++ b/resources/js/pages/PortalInvestidor.tsx @@ -0,0 +1,1181 @@ +import { createContext, useContext, useRef, useState } from "react"; +import { router } from "@inertiajs/react"; +import { PortalMenu } from "@/layouts/portal-navigation"; +import type { Dispatch, FormEvent, ReactNode, SetStateAction } from "react"; +import type { LucideIcon } from "lucide-react"; +import { usePortalQuery } from "@/hooks/use-portal-query"; +import { + PortalApiError, + fieldErrors, + interestPayload, + newSubmissionKey, + money, + portalRequest, + searchUrl, + toFeaturedOpportunity, + toOpportunity, +} from "@/lib/portal-api"; +import type { + Filters, + FormErrors, + HomeResponse, + InterestFormData, + Opportunity, + OpportunityRecord, + SearchResponse, + Sector, +} from "@/lib/portal-api"; +import { + Search, + Leaf, + Zap, + Hotel, + Truck, + Waves, + Banknote, + ArrowRight, + ArrowLeft, + MapPin, + CheckCircle2, +} from "lucide-react"; + +type View = "home" | "opportunities" | "detail" | "interest"; +type OpportunityAction = (opportunity: Opportunity) => void; +const SectorContext = createContext([]); +const SECTOR_ICONS: Record = { + agronegocio: Leaf, + energia: Zap, + turismo: Hotel, + infraestruturas: Truck, + "economia-azul": Waves, + "mercado-financeiro": Banknote, +}; + +function QueryStatus({ + loading, + error, + retry, +}: { + loading: boolean; + error?: string; + retry: () => void; +}) { + if (loading) + return ( +

+ A carregar dados... +

+ ); + if (!error) return null; + return ( +
+

{error}

+ +
+ ); +} + +const PROVINCES = [ + "Maputo Cidade", + "Maputo Província", + "Gaza", + "Inhambane", + "Sofala", + "Manica", + "Tete", + "Zambézia", + "Nampula", + "Cabo Delgado", + "Niassa", +]; + +const MATURITY_LEVELS = [ + "Ideia identificada", + "Pré-estudo disponível", + "Estudo de viabilidade em curso", + "Projecto estruturado", + "Pronto para investimento", + "Em negociação ou implementação", +]; + +/* ------------------------------------------------------------------ */ +/* Componentes */ +/* ------------------------------------------------------------------ */ + +function TopBar({ + onNavigate, + requestCount, +}: { + onNavigate: (view: View) => void; + requestCount: number; +}) { + return ( +
+
+ + +
+ + O meu painel + + + {requestCount} pedido{requestCount === 1 ? "" : "s"} recebido + {requestCount === 1 ? "" : "s"} + + +
+
+
+ ); +} + +function Hero({ + query, + setQuery, + onSearch, + onNavigate, + stats, +}: { + query: string; + setQuery: (value: string) => void; + onSearch: (event: FormEvent) => void; + onNavigate: (view: View) => void; + stats?: HomeResponse["data"]["stats"]; +}) { + return ( +
+
+
+

+ Portal do Investidor +

+

+ Oportunidades reais de investimento em Moçambique, + organizadas por sector. +

+

+ Consulte projectos por província e estágio de maturidade, compreenda os + requisitos legais e fiscais, e manifeste interesse directamente junto da + entidade promotora. +

+ +
+ + +
+
+ +
+ +
+ setQuery(e.target.value)} + placeholder="Ex.: agronegócio, Nampula, energia..." + className="w-full rounded-lg px-3 py-2.5 text-sm text-slate-900 ring-2 ring-transparent outline-none focus:ring-amber-400" + /> + +
+
+
+

+ {stats?.opportunities ?? "—"} +

+ oportunidades +
+
+

{stats?.sectors ?? "—"}

+ sectores +
+
+

+ {stats?.provinces ?? "—"} +

+ províncias +
+
+
+
+
+ ); +} + +function SectorGrid({ onSelectSector }: { onSelectSector: (slug: string) => void }) { + const sectors = useContext(SectorContext); + return ( +
+

Sectores estratégicos

+

+ Explore as áreas prioritárias do programa de investimento nacional. +

+ +
+ {sectors.map((sector) => { + const Icon = SECTOR_ICONS[sector.slug] ?? Banknote; + const count = sector.opportunitiesCount; + + return ( + + ); + })} +
+
+ ); +} + +function OpportunityCard({ + opportunity, + onView, + onInterest, +}: { + opportunity: Opportunity; + onView: OpportunityAction; + onInterest: OpportunityAction; +}) { + return ( +
+
+ {opportunity.featured && ( + + Destaque + + )} + + {opportunity.sectorName} + +
+ +

+ {opportunity.reference} +

+

{opportunity.title}

+

+ {opportunity.province ?? "Localização por indicar"} +

+ +
+
+
Investimento
+
+ {money(opportunity.amount, opportunity.currency)} +
+
+
+
Maturidade
+
+ {opportunity.maturity ?? "Por indicar"} +
+
+
+ +
+ + +
+
+ ); +} + +function OpportunitiesView({ + filters, + setFilters, + onView, + onInterest, +}: { + filters: Filters; + setFilters: Dispatch>; + onView: OpportunityAction; + onInterest: OpportunityAction; +}) { + const sectors = useContext(SectorContext); + const [page, setPage] = useState(1); + const query = usePortalQuery(searchUrl(filters, page), 250); + const results = query.data?.data.map(toOpportunity) ?? []; + const total = query.data?.total ?? 0; + function changeFilters(next: Filters) { + setFilters(next); + setPage(1); + } + + return ( +
+

Oportunidades de investimento

+

+ {query.loading ? ( + "A pesquisar oportunidades..." + ) : query.error ? ( + "Pesquisa indisponível." + ) : ( + <> + {total} oportunidade{total === 1 ? "" : "s"} encontrada + {total === 1 ? "" : "s"}. + + )} +

+ +
+ changeFilters({ ...filters, query: e.target.value })} + placeholder="Pesquisar..." + className="rounded-lg border border-slate-200 px-3 py-2 text-sm" + /> + + + +
+ +
+ + + +
+ + {!query.loading && + !query.error && + (results.length === 0 ? ( +
+ Nenhuma oportunidade corresponde aos filtros seleccionados. +
+ ) : ( +
+ {results.map((o) => ( + + ))} +
+ ))} + {query.data && query.data.last_page > 1 && ( + + )} +
+ ); +} + +function OpportunityDetail({ + opportunity: selected, + onBack, + onInterest, +}: { + opportunity: Opportunity; + onBack: () => void; + onInterest: OpportunityAction; +}) { + const query = usePortalQuery<{ success: boolean; data: OpportunityRecord }>( + "/api/v1/opportunities/" + encodeURIComponent(selected.slug), + ); + if (!query.data) + return ( +
+ + +
+ ); + const opportunity = toOpportunity(query.data.data); + + return ( +
+ + +

+ {opportunity.reference} +

+

{opportunity.title}

+

+ {opportunity.province ?? "Localização por indicar"} ·{" "} + {opportunity.sectorName} +

+ +

+ {opportunity.description ?? opportunity.summary ?? "Descrição por indicar"} +

+ +
+
+
Investimento estimado
+
+ {money(opportunity.amount, opportunity.currency)} +
+
+
+
Maturidade
+
+ {opportunity.maturity ?? "Por indicar"} +
+
+
+
Financiamento pretendido
+
+ {opportunity.financing ?? "Por indicar"} +
+
+
+
Impacto esperado
+
+ {opportunity.impact ?? "Por indicar"} +
+
+
+ +
+ A informação legal, fiscal e financeira apresentada é orientadora e deve ser + confirmada junto das entidades competentes antes da tomada de decisão. +
+ + +
+ ); +} + +function InterestForm({ + preselected, + onSubmitted, +}: { + preselected: Opportunity | null; + onSubmitted: () => void; +}) { + const sectors = useContext(SectorContext); + const [submitting, setSubmitting] = useState(false); + const pending = useRef(false); + const submission = useRef<{ body: string; key: string } | null>(null); + const [submitError, setSubmitError] = useState(null); + const [form, setForm] = useState({ + name: "", + email: "", + phone: "", + investorType: "", + country: "Moçambique", + sectorInterest: preselected?.sectorName ?? "", + opportunityReference: preselected ? preselected.reference : "", + investmentRange: "", + message: "", + privacyConsent: false, + }); + const [errors, setErrors] = useState({}); + const [result, setResult] = useState<{ reference: string } | null>(null); + + function update(field: K, value: InterestFormData[K]) { + setForm((prev) => ({ ...prev, [field]: value })); + } + + function validate() { + const next: FormErrors = {}; + if (form.name.trim().length < 3) next.name = "Indique o nome completo."; + if (!/^\S+@\S+\.\S+$/.test(form.email)) next.email = "Indique um email válido."; + if (!form.country.trim()) next.country = "Indique o país."; + if (!form.investorType) next.investorType = "Seleccione o tipo de investidor."; + if (!form.sectorInterest.trim()) next.sectorInterest = "Indique o sector de interesse."; + if (form.message.trim().length < 20) + next.message = "A mensagem deve ter pelo menos 20 caracteres."; + if (!form.privacyConsent) + next.privacyConsent = "É necessário aceitar a política de privacidade."; + setErrors(next); + return Object.keys(next).length === 0; + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (pending.current || !validate()) return; + pending.current = true; + setSubmitting(true); + setSubmitError(null); + try { + const body = JSON.stringify(interestPayload(form)); + if (submission.current?.body !== body) { + submission.current = { body, key: newSubmissionKey() }; + } + const response = await portalRequest<{ + success: boolean; + data: { reference: string }; + }>("/api/v1/interest-requests", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": submission.current.key, + }, + body, + }); + if (!response.success || typeof response.data?.reference !== "string") + throw new Error("Invalid confirmation"); + setResult({ reference: response.data.reference }); + onSubmitted(); + } catch (error: unknown) { + if (error instanceof PortalApiError) { + setErrors(fieldErrors(error)); + setSubmitError(error.message); + } else { + setSubmitError( + "Não foi possível confirmar o envio. Verifique a ligação antes de tentar novamente.", + ); + } + } finally { + pending.current = false; + setSubmitting(false); + } + } + + if (result) { + return ( +
+ +

+ Manifestação de interesse registada +

+

+ A sua referência é {result.reference}. A equipa do portal irá + analisar o pedido e poderá solicitar informação adicional. +

+
+ ); + } + + return ( +
+

Manifestar interesse

+

+ Preencha os dados abaixo para que a equipa do portal possa analisar o seu interesse + e entrar em contacto. +

+ + {preselected && ( +
+ Oportunidade seleccionada: {preselected.title} ( + {preselected.reference}) +
+ )} + +
+ {submitError && ( +

+ {submitError} +

+ )} +
+ + update("name", e.target.value)} + /> + + + + update("email", e.target.value)} + /> + + + + update("phone", e.target.value)} + /> + + + + + + + + update("country", e.target.value)} + /> + + + + + + + + + + + +