119 lines
7.8 KiB
PHP
119 lines
7.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
/** An explicit extension of the existing authenticated resource installer. */
|
|
class OrganizationCountryInstallation
|
|
{
|
|
public const MIGRATION = '2026_10_10_050000_extend_organization_country';
|
|
|
|
/** @return list<string> */
|
|
public function files(): array
|
|
{
|
|
return ['app/Services/OrganizationCountry.php', 'app/Services/OrganizationCountryInstallation.php', 'app/Providers/AppServiceProvider.php',
|
|
'app/Services/CountryCatalogue.php', 'app/Services/PortalProfileAccess.php', 'app/Services/PortalProfileInstallation.php',
|
|
'app/Http/Controllers/InvestmentWorkspaceController.php', 'app/Http/Controllers/InvestmentSubmissionController.php',
|
|
'app/Http/Controllers/OpportunityResourceController.php', 'app/Models/User.php', 'config/portal.php',
|
|
'routes/web.php', 'routes/investment.php', 'database/migrations/'.self::MIGRATION.'.php',
|
|
'resources/js/components/country-select.tsx', 'resources/js/pages/investment/profile.tsx',
|
|
'resources/js/pages/investment/submission.tsx', 'resources/js/pages/management/opportunity-install.tsx',
|
|
'resources/data/iso3166.tab', 'config/portal_profiles.php', 'composer.json', 'composer.lock'];
|
|
}
|
|
|
|
public function fingerprint(): string
|
|
{
|
|
return hash('sha256', json_encode(array_map(fn ($file) => [$file, hash_file('sha256', base_path($file))], $this->files()), JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
/** Separate bounded evidence: partial opportunity modules are not an installation dependency.
|
|
* @return array<string,mixed> */
|
|
public function evidence(object $identity): array
|
|
{
|
|
$path = storage_path('app/private/investmoz-v2/organization-country-installation/readiness.json');
|
|
$bytes = is_file($path) ? file_get_contents($path) : false;
|
|
$p = is_string($bytes) ? json_decode($bytes, true) : [];
|
|
$p = is_array($p) ? $p : [];
|
|
$target = get_object_vars($identity);
|
|
$ready = ($p['code_hash'] ?? '') === $this->fingerprint()
|
|
&& isset($target['database'], $target['port'])
|
|
&& ($p['target']['database'] ?? '') === $target['database'] && (int) ($p['target']['port'] ?? 0) === (int) $target['port']
|
|
&& strtotime($p['verified_at'] ?? '') >= time() - 86400
|
|
&& ($p['restore_verified'] ?? false) && ($p['files_verified'] ?? false) && ($p['qa_passed'] ?? false)
|
|
&& ($p['compatibility_verified'] ?? false) && ($p['reversal_verified'] ?? false)
|
|
&& is_file($p['dump_path'] ?? '') && hash_file('sha256', $p['dump_path']) === ($p['dump_sha256'] ?? '')
|
|
&& is_file($p['files_path'] ?? '') && hash_file('sha256', $p['files_path']) === ($p['files_sha256'] ?? '');
|
|
|
|
return ['ready' => $ready, 'backupDate' => $p['verified_at'] ?? null];
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
public function preview(User $actor): array
|
|
{
|
|
app(PortalProfileInstallation::class)->authorize($actor);
|
|
$identity = DB::selectOne('SELECT current_database() AS database, inet_server_port() AS port');
|
|
$evidence = $this->evidence($identity);
|
|
$columns = array_map(fn ($column) => Schema::hasColumn('organizations', $column), ['incorporation_country_code', 'incorporation_country_provenance']);
|
|
$ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists();
|
|
$installed = $ledger && ! in_array(false, $columns, true);
|
|
$consistent = ($ledger && ! in_array(false, $columns, true)) || (! $ledger && ! in_array(true, $columns, true));
|
|
$dependencies = array_reduce(['organizations', 'memberships', 'audit_events'], fn ($ok, $table) => $ok && Schema::hasTable($table), true);
|
|
$plan = ['migration' => self::MIGRATION, 'installed' => $installed, 'consistent' => $consistent, 'ready' => $consistent && $dependencies && $evidence['ready'],
|
|
'target' => $identity, 'code_hash' => $this->fingerprint(), 'backupDate' => $evidence['backupDate'],
|
|
'dependencies' => ['existing_organizations_memberships_audit' => $dependencies],
|
|
'changes' => ['Duas colunas opcionais no cadastro mestre organizations; zero tabelas, organizações, contas ou atribuições.',
|
|
'Preserva source_system, external_id, valores anteriores, versões e submissões; reutilização explícita, sem inferência por nome.',
|
|
'Ação independente de 030000, 040000 e 190000; não ativa flags, cálculos, publicação nem o percurso.'],
|
|
'rollback' => 'Desabilitar o consumidor mantém os dados. Não remover colunas com histórico; ensaiar a reversão de código e, se necessário, restauro no clone verificado.'];
|
|
$plan['hash'] = hash('sha256', json_encode($plan, JSON_THROW_ON_ERROR));
|
|
|
|
return $plan;
|
|
}
|
|
|
|
/** @param array<string, mixed> $input
|
|
* @return array<string, mixed> */
|
|
public function apply(User $actor, array $input): array
|
|
{
|
|
app(PortalProfileInstallation::class)->authorize($actor, true);
|
|
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted',
|
|
'idempotency_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:1000'])->validate();
|
|
|
|
return DB::transaction(function () use ($actor, $data) {
|
|
DB::select('SELECT pg_advisory_xact_lock(101026,50000)');
|
|
$requestHash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
|
$previous = DB::table('audit_events')->where('source_system', 'organization-country-installation')->where('actor_user_id', $actor->id)->where('correlation_id', $data['idempotency_key'])->first();
|
|
if ($previous) {
|
|
$receipt = json_decode($previous->after_data, true, 512, JSON_THROW_ON_ERROR);
|
|
abort_unless(hash_equals($receipt['request_hash'], $requestHash), 409, 'Chave de repetição reutilizada com outro conteúdo.');
|
|
|
|
return $receipt['result'];
|
|
}
|
|
$plan = $this->preview($actor);
|
|
abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Plano alterado; consulte a pré-visualização.');
|
|
abort_unless($plan['consistent'], 409, 'Ledger/schema incoerentes. Recuperar antes de instalar.');
|
|
$changed = ! $plan['installed'];
|
|
if ($changed) {
|
|
abort_unless($plan['ready'], 422, 'Exige provas atuais da revisão, alvo, backup/restauro e compatibilidade.');
|
|
config(['organization_country.authenticated_installation' => true]);
|
|
try {
|
|
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500);
|
|
} finally {
|
|
config(['organization_country.authenticated_installation' => false]);
|
|
}
|
|
abort_unless($this->preview($actor)['installed'], 500, 'Verificação pós-instalação falhou.');
|
|
}
|
|
$result = ['installed' => true, 'changed' => $changed, 'migration' => self::MIGRATION, 'new_columns' => $changed ? 2 : 0, 'new_tables' => 0, 'new_organizations' => 0, 'flags_changed' => false];
|
|
DB::table('audit_events')->insert(['source_system' => 'organization-country-installation', 'entity_type' => 'installation', 'action' => 'install',
|
|
'actor_user_id' => $actor->id, 'correlation_id' => $data['idempotency_key'], 'data_classification' => 'restricted',
|
|
'after_data' => json_encode(['request_hash' => $requestHash, 'reason' => $data['reason'], 'result' => $result, 'code_hash' => $plan['code_hash']], JSON_THROW_ON_ERROR)]);
|
|
|
|
return $result;
|
|
});
|
|
}
|
|
}
|