300 lines
20 KiB
PHP
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);
|
|
}
|
|
}
|