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); } }