Files
SGF/app/Modules/Identity/Services/ModuleAssociationReview.php
2026-11-02 20:29:34 +02:00

300 lines
20 KiB
PHP

<?php
namespace App\Modules\Identity\Services;
use App\Shared\Context\{CompanyContext, ModuleNavigation, ModuleRecords};
use Illuminate\Support\Facades\{Crypt, DB};
use Illuminate\Http\Request;
use Illuminate\Support\Str;
final class ModuleAssociationReview
{
public function __construct(private CompanyContext $context, private Permissions $permissions) {}
private function scope(string $area, bool $edit = false): array
{
$s = $this->context->identifiers();
$unit = str_ends_with($area, '_drafts') ? $this->context->unitId() : null;
abort_if(str_ends_with($area, '_drafts') && !$unit, 403);
foreach (['access.manage', "$area.view", ...($edit ? ["$area.edit"] : [])] as $right) {
$this->permissions->require($this->context->userId(), $s['tenant_id'], $s['company_id'], $right, $right === 'access.manage' ? null : $unit);
}
return $s + ['unit_id' => $unit];
}
private function events(string $area, int $id)
{
return DB::table('identity_audit')->where($this->context->identifiers())
->whereIn('action', ['module.record.associated', 'module.record.disassociated', 'module.record.reviewed'])
->whereRaw("details::jsonb->>'area' = ?", [$area])->whereRaw("details::jsonb->>'record_id' = ?", [(string) $id]);
}
private function links(string $area, int $id): array
{
$events = $this->events($area, $id)->where('action', '<>', 'module.record.reviewed')
->selectRaw("DISTINCT ON (details::jsonb->>'module_id') action,details")
->orderByRaw("details::jsonb->>'module_id'")->orderByDesc('id')->get();
$modules = $events
->filter(fn ($e) => $e->action === 'module.record.associated')
->map(fn ($e) => json_decode($e->details, true)['module_id'])->sort()->values()->all();
return ['modules' => $modules, 'revision' => (int) ($this->events($area, $id)->max('id') ?? 0)];
}
private function row(string $area, int $id, bool $edit = false): object
{
$row = DB::table('a_'.$area)->where($this->scope($area, $edit))->where('id', $id)->first();
abort_unless($row, 403, 'Registo ou dependência fora do âmbito autorizado.');
return $row;
}
// Traverse explicit references, never infer a destination or grant access.
private function graph(string $area, int $id, array &$seen): void
{
$key = "$area:$id";
if (isset($seen[$key])) return;
$row = $this->row($area, $id);
$links = $this->links($area, $id);
$seen[$key] = ['area' => $area, 'id' => $id, 'version' => (int) $row->version,
'checksum' => hash('sha256', json_encode((array) $row, JSON_THROW_ON_ERROR)), ...$links];
$data = json_decode($row->data, true, 512, JSON_THROW_ON_ERROR);
if (isset($row->client_id)) $this->graph('clients', (int) $row->client_id, $seen);
if (isset($row->source_id)) $this->graph('service_drafts', (int) $row->source_id, $seen);
foreach (str_ends_with($area, '_drafts') ? ($data['lines'] ?? []) : [] as $line) {
if (isset($line['service_id'])) $this->graph('services', (int) $line['service_id'], $seen);
}
}
private function snapshot(string $area, int $id): array
{
$graph = [];
$this->graph($area, $id, $graph);
ksort($graph);
$rights = DB::table('permission_grants')->where($this->context->identifiers())->where('user_id', $this->context->userId())->orderBy('id')->get();
return ['graph' => $graph, 'modules' => app(ModuleNavigation::class)->available($this->context),
'permissions' => hash('sha256', $rights->toJson())];
}
private function digest(array $value): string { return hash('sha256', json_encode($value, JSON_THROW_ON_ERROR)); }
public function listing(Request $r): array
{
abort_unless(hash_equals((string) $r->session()->get('company_context.token'), (string) $r->header('X-SGF-Context')), 409, 'O contexto mudou. Reabra a fila.');
$d = $r->validate(['area' => 'sometimes|in:'.implode(',', ModuleRecords::AREAS), 'state' => 'sometimes|in:all,pending,associated,removed',
'page' => 'sometimes|integer|min:1|max:100000', 'per_page' => 'sometimes|integer|min:1|max:100', 'search' => 'nullable|string|max:100',
'unit_id' => 'sometimes|integer|min:1']);
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
$area = $d['area'] ?? 'clients';
$scope = $this->scope($area);
if (isset($d['unit_id'])) abort_unless((int) $d['unit_id'] === $this->context->unitId(), 403);
$query = DB::table('a_'.$area)->where($scope);
$active = "EXISTS (SELECT 1 FROM identity_audit ma WHERE ma.tenant_id=a_{$area}.tenant_id AND ma.company_id=a_{$area}.company_id
AND ma.action='module.record.associated' AND ma.details::jsonb->>'area'=? AND ma.details::jsonb->>'record_id'=a_{$area}.id::text
AND NOT EXISTS (SELECT 1 FROM identity_audit mr WHERE mr.tenant_id=ma.tenant_id AND mr.company_id=ma.company_id AND mr.id>ma.id
AND mr.action='module.record.disassociated' AND mr.details::jsonb->>'area'=ma.details::jsonb->>'area'
AND mr.details::jsonb->>'record_id'=ma.details::jsonb->>'record_id' AND mr.details::jsonb->>'module_id'=ma.details::jsonb->>'module_id'))";
$ever = "EXISTS (SELECT 1 FROM identity_audit me WHERE me.tenant_id=a_{$area}.tenant_id AND me.company_id=a_{$area}.company_id
AND me.action IN ('module.record.associated','module.record.disassociated') AND me.details::jsonb->>'area'=? AND me.details::jsonb->>'record_id'=a_{$area}.id::text)";
match ($d['state'] ?? 'all') {
'associated' => $query->whereRaw($active, [$area]),
'pending' => $query->whereRaw("NOT $ever", [$area]),
'removed' => $query->whereRaw("NOT $active AND $ever", [$area, $area]),
default => null,
};
if (($d['search'] ?? '') !== '') $query->where('name', 'ilike', '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $d['search']).'%');
$page = $query->orderBy('id')->paginate($d['per_page'] ?? 25, $this->pageColumns($area, true), 'page', $d['page'] ?? 1);
$rows = $this->describePage($area, $page->items(), $scope);
return ['records' => [$area => $rows], 'modules' => app(ModuleNavigation::class)->available($this->context),
'pagination' => ['page' => $page->currentPage(), 'per_page' => $page->perPage(), 'total' => $page->total(), 'last_page' => $page->lastPage()],
'notice' => 'Cada decisão exige conferência individual. Dependências não são associadas automaticamente.'];
}
/** Request-local data only: the selected page and its explicit dependency closure.
* Never reused by preview/confirm, which still read and lock current state.
*/
private function describePage(string $area, array $roots, array $scope): array
{
$records = []; $edges = []; $scopes = [$area => $scope]; $pending = [];
foreach ($roots as $row) { $records[$area][$row->id] = $row; $pending["$area:$row->id"] = [$area, (int) $row->id]; }
while ($pending) {
$needed = [];
foreach ($pending as $key => [$kind, $id]) {
$row = $records[$kind][$id]; $refs = [];
if (isset($row->client_id)) $refs[] = ['clients', (int) $row->client_id];
if (isset($row->source_id)) $refs[] = ['service_drafts', (int) $row->source_id];
if (str_ends_with($kind, '_drafts')) {
foreach (json_decode($row->association_service_ids, true, 512, JSON_THROW_ON_ERROR) as $serviceId) $refs[] = ['services', (int) $serviceId];
}
$edges[$key] = $refs;
foreach ($refs as [$depArea, $depId]) if (!array_key_exists($depId, $records[$depArea] ?? [])) $needed[$depArea][$depId] = $depId;
}
$pending = [];
foreach ($needed as $depArea => $ids) {
if (!array_key_exists($depArea, $scopes)) {
try { $scopes[$depArea] = $this->scope($depArea); }
catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { if ($e->getStatusCode() !== 403) throw $e; $scopes[$depArea] = null; }
}
foreach ($ids as $id) $records[$depArea][$id] = null;
if ($scopes[$depArea] === null) continue;
foreach (array_chunk(array_values($ids), 500) as $chunk) {
foreach (DB::table('a_'.$depArea)->where($scopes[$depArea])->whereIn('id', $chunk)->get($this->pageColumns($depArea)) as $row) {
$records[$depArea][$row->id] = $row;
$pending["$depArea:$row->id"] = [$depArea, (int) $row->id];
}
}
}
}
$states = $this->pageStates($records);
$out = [];
foreach ($roots as $row) {
$deps = []; $restricted = false;
try { $seen = []; $this->walkPage($area, $row->id, $records, $edges, $states, $seen); unset($seen["$area:$row->id"]); $deps = array_values($seen); }
catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { if ($e->getStatusCode() !== 403) throw $e; $restricted = true; }
$out[] = ['id' => $row->id, 'name' => $row->name, 'version' => $row->version, ...$states[$area][$row->id], 'dependencies' => $deps, 'dependencies_restricted' => $restricted];
}
return $out;
}
/** Fetch metadata and ordered references, not document bodies. This projection
* is only for listing; the decision snapshot still hashes the complete rows.
*/
private function pageColumns(string $area, bool $root = false): array
{
$columns = $root ? ['id', 'name', 'version'] : ['id', 'version'];
if (str_ends_with($area, '_drafts')) {
$columns[] = 'client_id';
if ($area === 'billing_drafts') $columns[] = 'source_id';
$columns[] = DB::raw("(SELECT coalesce(jsonb_agg(ref.line->>'service_id' ORDER BY ref.ord)
FILTER (WHERE ref.line->>'service_id' IS NOT NULL),'[]'::jsonb)
FROM jsonb_array_elements(data->'lines') WITH ORDINALITY AS ref(line,ord)) AS association_service_ids");
}
return $columns;
}
private function walkPage(string $area, int $id, array $records, array $edges, array $states, array &$seen): void
{
$key = "$area:$id";
if (isset($seen[$key])) return;
$row = $records[$area][$id] ?? null;
abort_unless($row, 403, 'Dependência fora do âmbito autorizado.');
$seen[$key] = ['area' => $area, 'id' => $id, 'version' => (int) $row->version, ...$states[$area][$id]];
foreach ($edges[$key] ?? [] as [$depArea, $depId]) $this->walkPage($depArea, $depId, $records, $edges, $states, $seen);
}
/** SQL reduces history to one state per selected record; no event universe in PHP.
* Review-only events advance revision but never create an active association.
*/
private function pageStates(array $records): array
{
$out = [];
foreach ($records as $area => $items) {
$ids = [];
foreach ($items as $id => $row) if ($row !== null) { $ids[] = (string) $id; $out[$area][$id] = ['modules' => [], 'revision' => 0]; }
foreach (array_chunk($ids, 500) as $chunk) {
$q = DB::table('identity_audit')->where($this->context->identifiers())
->whereIn('action', ['module.record.associated', 'module.record.disassociated', 'module.record.reviewed'])
->whereRaw("details::jsonb->>'area' = ?", [$area])->whereIn(DB::raw("details::jsonb->>'record_id'"), $chunk)
->selectRaw("id,action,details::jsonb->>'record_id' AS record_id,details::jsonb->>'module_id' AS module_id");
$sql = "WITH scoped AS MATERIALIZED (".$q->toSql()."),
latest AS (SELECT DISTINCT ON (record_id,module_id) record_id,module_id,action FROM scoped
WHERE action <> 'module.record.reviewed' ORDER BY record_id,module_id,id DESC),
revisions AS (SELECT record_id,max(id) AS revision FROM scoped GROUP BY record_id)
SELECT r.record_id,r.revision,coalesce(jsonb_agg(l.module_id ORDER BY l.module_id)
FILTER (WHERE l.action='module.record.associated'),'[]'::jsonb) AS modules
FROM revisions r LEFT JOIN latest l ON l.record_id=r.record_id GROUP BY r.record_id,r.revision";
foreach (DB::select($sql, $q->getBindings()) as $row) $out[$area][(int) $row->record_id] = ['modules' => json_decode($row->modules, true, 512, JSON_THROW_ON_ERROR), 'revision' => (int) $row->revision];
}
}
return $out;
}
public function preview(Request $r): array
{
$d = $this->input($r);
return DB::transaction(function () use ($d, $r) {
$s = $this->context->identifiers();
DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($this->context->userId(), $s['tenant_id'], $s['company_id'], $this->context->unitId());
$row = $this->row($d['area'], $d['id'], true);
abort_unless((int) $row->version === (int) $d['version'], 409, 'O registo mudou. Faça nova conferência.');
$snapshot = $this->snapshot($d['area'], $d['id']);
abort_unless(in_array($d['module_id'], array_column($snapshot['modules'], 'id'), true), 403);
$before = $snapshot['graph'][$d['area'].':'.$d['id']]['modules'];
$after = $this->after($before, $d);
$dependencies = $snapshot['graph'];
unset($dependencies[$d['area'].':'.$d['id']]);
$missing = array_values(array_filter($dependencies, fn ($g) => !in_array($d['module_id'], $g['modules'], true)));
$payload = ['decision_id' => (string) Str::uuid(), 'actor' => $this->context->userId(), 'scope' => $this->context->identifiers(),
'unit' => $this->context->unitId(), 'context' => hash('sha256', $r->header('X-SGF-Context')), 'expires' => time() + 900,
'input' => $d, 'review_hash' => $this->digest($snapshot)];
return ['review_token' => Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)), 'record' => ['area' => $d['area'], 'id' => $row->id, 'name' => $row->name, 'version' => $row->version],
'before' => $before, 'after' => $after, 'dependencies' => array_values(array_map(fn ($g) => array_diff_key($g, ['checksum' => true]), $snapshot['graph'])),
'reason' => $d['reason'], 'operation' => $d['operation'], 'module_id' => $d['module_id'],
'warnings' => [$d['operation'] === 'associate'
? count($missing).' dependências sem associação ao destino escolhido. Continuam pendentes; esta decisão não as disponibiliza nesse módulo.'
: 'A retirada pode impedir a consulta deste registo e a sua selecção como origem nesse módulo. Registos que o referenciam e documentos anteriores permanecem preservados.'],
'history' => $this->events($d['area'], $d['id'])->orderByDesc('id')->limit(20)->get(['id', 'action', 'actor_id', 'created_at', 'details']),
'impact' => 'Altera apenas a associação deste registo. Não altera direitos, dependências, origens ou responsabilidade financeira. A visibilidade continua sujeita aos direitos originais.'];
});
}
private function input(Request $r): array
{
$d = $r->validate(['area' => 'required|in:'.implode(',', ModuleRecords::AREAS), 'id' => 'required|integer|min:1', 'version' => 'required|integer|min:1',
'module_id' => 'required|string|max:50', 'reason' => 'required|string|min:3|max:500', 'operation' => 'required|in:associate,remove']);
abort_if(array_diff(array_keys($r->all()), array_keys($d)) !== [], 422);
abort_if(mb_strlen(trim($d['reason'])) < 3, 422);
return $d;
}
private function after(array $before, array $d): array
{
$after = $d['operation'] === 'remove' ? array_values(array_diff($before, [$d['module_id']])) : array_values(array_unique([...$before, $d['module_id']]));
sort($after); return $after;
}
public function confirm(Request $r): array
{
$d = $r->validate(['review_token' => 'required|string|max:30000']);
abort_if(array_keys($r->all()) !== ['review_token'], 422);
try { $p = json_decode(Crypt::decryptString($d['review_token']), true, 512, JSON_THROW_ON_ERROR); }
catch (\Throwable) { abort(422, 'Conferência inválida.'); }
abort_unless(is_array($p) && isset($p['actor'], $p['scope'], $p['context'], $p['expires'], $p['input'], $p['review_hash'], $p['decision_id'])
&& array_key_exists('unit', $p), 422, 'Conferência inválida.');
abort_unless($p['actor'] === $this->context->userId() && $p['scope'] === $this->context->identifiers() && $p['unit'] === $this->context->unitId()
&& hash_equals($p['context'], hash('sha256', $r->header('X-SGF-Context'))), 409, 'O contexto mudou. Faça nova conferência.');
abort_if($p['expires'] < time(), 409, 'Conferência expirada.');
return DB::transaction(function () use ($p) {
$d = $p['input'];
$s = $this->context->identifiers();
DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($this->context->userId(), $s['tenant_id'], $s['company_id'], $this->context->unitId());
// Existing identity administration also serializes through this company.
$this->row($d['area'], $d['id'], true);
$initial = $this->snapshot($d['area'], $d['id']);
foreach ($initial['graph'] as $g) {
DB::table('a_'.$g['area'])->where($this->scope($g['area']))->where('id', $g['id'])->lockForUpdate()->firstOrFail();
}
$current = $this->snapshot($d['area'], $d['id']);
abort_unless($this->digest($initial) === $this->digest($current), 409, 'As dependências mudaram. Faça nova conferência.');
abort_unless(in_array($d['module_id'], array_column($current['modules'], 'id'), true), 403);
$receipt = $this->events($d['area'], $d['id'])->whereRaw("details::jsonb->>'decision_id' = ?", [$p['decision_id']])->first();
$key = $d['area'].':'.$d['id'];
if ($receipt) {
$details = json_decode($receipt->details, true);
abort_unless($current['graph'][$key]['revision'] === (int) $receipt->id && $current['graph'][$key]['modules'] === $details['after_modules'], 409, 'A associação mudou. Faça nova conferência.');
$current['graph'][$key]['revision'] = $details['before_revision'];
$current['graph'][$key]['modules'] = $details['before_modules'];
abort_unless(hash_equals($p['review_hash'], $this->digest($current)), 409, 'O registo ou dependências mudaram. Faça nova conferência.');
return ['ok' => true, 'replayed' => true, 'event_id' => $receipt->id];
}
abort_unless(hash_equals($p['review_hash'], $this->digest($current)), 409, 'O registo, permissões ou dependências mudaram. Faça nova conferência.');
$before = $current['graph'][$key]['modules']; $after = $this->after($before, $d);
$action = $before === $after ? 'module.record.reviewed' : ($d['operation'] === 'remove' ? 'module.record.disassociated' : 'module.record.associated');
Audit::record($action, $this->context->userId(), $p['scope']['tenant_id'], $p['scope']['company_id'], str_ends_with($d['area'], '_drafts') ? $p['unit'] : null,
details: ['area' => $d['area'], 'record_id' => $d['id'], 'version' => $d['version'], 'module_id' => $d['module_id'], 'reason' => $d['reason'],
'decision_id' => $p['decision_id'], 'review_hash' => $p['review_hash'], 'before_modules' => $before, 'after_modules' => $after, 'before_revision' => $current['graph'][$key]['revision']]);
return ['ok' => true, 'replayed' => false, 'event_id' => $this->events($d['area'], $d['id'])->max('id')];
}, 3);
}
}