,links:list>,files:list>,document_changes:array,contributors:list,policy:array{independent_required:bool}} */ final class PortalEditorialWorkflow { private const STRUCTURE_FIELDS = [ 'projects' => ['code', 'name', 'status', 'description', 'budget_amount', 'budget_currency', 'start_date', 'end_date', 'primary_territory_id', 'territory_version'], 'economic_clusters' => ['code', 'name', 'status', 'sector', 'primary_territory_id', 'territory_version'], 'development_corridors' => ['code', 'name', 'status', 'description', 'primary_territory_id', 'territory_version'], ]; public function available(): bool { return (bool) config('editorial_workflow.enabled') && Schema::hasTable('portal_editorial_editions') && Schema::hasColumn('portal_editorial_notifications', 'opportunity_id'); } public function enabled(): bool { if (! config('editorial_workflow.enabled')) { return false; } abort_unless($this->available(), 503, 'Estrutura editorial ainda não instalada.'); return true; } public function authorize(User $actor, int $resource, bool $write = false): void { abort_unless($this->enabled(), 503, 'Circuito editorial ainda não instalado/activado neste ambiente.'); abort_unless($actor->hasVerifiedEmail() && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403); app(DocumentManagementAccess::class)->assertEditableResource($actor, $resource); if ($edition = $this->latest($resource)) { foreach ($this->payload($edition)['files'] as $file) { abort_unless($this->canReadFile($actor, $file['id']), 403); } } if ($write) { abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.'); } } public function canReadFile(User $actor, string $id): bool { return app(DocumentManagementAccess::class)->full($actor) || app(DocumentManagementAccess::class)->documents($actor)->where('id', $id)->exists() || app(EditorialReading::class)->publicResources()->where('d.id', $id)->exists(); } public function fresh(\stdClass $e): bool { $p = $this->payload($e); try { return hash_equals($e->content_hash, $this->hash($this->refreshPayload($e, $p))); } catch (HttpException $exception) { if (in_array($exception->getStatusCode(), [404, 422], true)) { return false; } throw $exception; } } public function latest(int $resource): ?\stdClass { return $this->enabled() ? DB::table('portal_editorial_editions')->where('resource_id', $resource)->orderByDesc('revision_no')->first() : null; } /** @return EditorialPayload */ public function payload(\stdClass $edition): array { return json_decode($edition->payload, true, 512, JSON_THROW_ON_ERROR); } public function workingResource(\stdClass $resource): \stdClass { $e = $this->latest((int) $resource->id); return $e ? (object) [...(array) $resource, ...$this->payload($e)['resource'], 'metadata_version' => max((int) $resource->metadata_version, (int) $e->revision_no), 'published' => $resource->published] : $resource; } public function queue(User $actor): Builder { abort_unless($this->enabled(), 503); $access = app(DocumentManagementAccess::class); abort_unless($this->canEnter($actor), 403); $query = DB::table('portal_editorial_editions as e')->join('portal_resources as r', 'r.id', '=', 'e.resource_id')->leftJoin('users as u', 'u.id', '=', 'e.assigned_to') ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('portal_editorial_editions as later')->whereColumn('later.resource_id', 'e.resource_id')->whereColumn('later.revision_no', '>', 'e.revision_no')); if (! ($access->full($actor) || $access->editorial($actor))) { $query->whereRaw('false'); } elseif (! $access->full($actor)) { $query->whereIn('r.id', $access->editorialResources($actor)->select('r.id')); } return $query; } public function canEnter(User $actor): bool { if (! $actor->hasVerifiedEmail() || app(PortalProfileAccess::class)->has($actor, 'auditor')) { return false; } $access = app(DocumentManagementAccess::class); if ($access->full($actor) || $access->editorial($actor)) { return true; } $navigation = app(PortalProfileAccess::class)->navigation($actor); return ! empty($navigation['mine']) || ! empty($navigation['review']); } public function opportunityPending(User $actor): int { $profiles = app(PortalProfileAccess::class); if (! $profiles->available()) { return 0; } return $profiles->scope(Opportunity::query(), $actor)->whereRaw("COALESCE(workflow_context->>'working_state',status) IN ('draft','submitted','in_review','information_pending','needs_information','approved')")->count(); } public function notifyOpportunity(User $actor, Opportunity $record, string $action): void { if (! $this->available()) { return; } $context = $record->workflow_context ?? []; $recipients = array_unique(array_filter([$record->owner_user_id, $context['assignment']['assignee_user_id'] ?? null, $context['publication_assignment']['assignee_user_id'] ?? null])); foreach ($recipients as $recipient) { if ((int) $recipient === $actor->id) { continue; } DB::table('portal_editorial_notifications')->insertOrIgnore(['user_id' => $recipient, 'edition_id' => null, 'opportunity_id' => $record->id, 'opportunity_version' => $record->management_version, 'content_hash' => $context['submission']['hash'] ?? $context['approval']['hash'] ?? null, 'action' => $action, 'created_at' => now()]); } } public function visibleNotice(User $actor, \stdClass $notice): bool { if ((int) $notice->user_id !== $actor->id || ! $this->available() || ! $this->canEnter($actor)) { return false; } if ($notice->opportunity_id !== null) { $record = Opportunity::query()->whereKey($notice->opportunity_id)->first(); return $record !== null && app(PortalProfileAccess::class)->canView($actor, $record); } $edition = DB::table('portal_editorial_editions')->where('id', $notice->edition_id)->first(); if (! $edition) { return false; } try { if ($edition->dossier_id ?? null) { if (! config('editorial_workflow.portfolio')) { return false; } $this->authorizePortfolio($actor, $edition->dossier_id); } else { $this->authorize($actor, (int) $edition->resource_id); } return true; } catch (HttpException $exception) { if (in_array($exception->getStatusCode(), [403, 404], true)) { return false; } throw $exception; } } /** @param array $resource */ private function independentPolicy(int $id, array $resource): bool { $kind = ($resource['legislation'] ?? false) || ($resource['document_type'] ?? '') === 'legislation' ? 'legislation' : (! empty($resource['editorial_content']) ? 'article' : 'document'); return in_array($id, config('editorial_workflow.independent_approval.resources', []), true) || in_array($kind, config('editorial_workflow.independent_approval.kinds', []), true); } public function requiresIndependent(\stdClass $edition): bool { $p = $this->payload($edition); return $p['policy']['independent_required'] || $this->independentPolicy((int) $edition->resource_id, $p['resource']); } /** @return list> */ public function notices(User $actor): array { if (! $this->available()) { return []; } abort_unless($this->canEnter($actor), 403); $result = []; foreach (DB::table('portal_editorial_notifications')->where('user_id', $actor->id)->lazyByIdDesc(100) as $notice) { if (! $this->visibleNotice($actor, $notice)) { continue; } $edition = $notice->edition_id ? DB::table('portal_editorial_editions')->where('id', $notice->edition_id)->first() : null; $result[] = ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at, 'href' => $notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : (($edition->dossier_id ?? null) ? '/management/editorial/portfolio/'.$edition->dossier_id : '/management/editorial/resources/'.$edition->resource_id).'?edition='.$notice->edition_id, 'reference' => $notice->opportunity_id !== null ? 'Revisão '.$notice->opportunity_version : 'Edição '.$edition->revision_no]; if (count($result) === 50) { break; } } return $result; } public function unreadCount(User $actor): int { $count = 0; foreach (DB::table('portal_editorial_notifications')->where('user_id', $actor->id)->whereNull('read_at')->lazyByIdDesc(100) as $notice) { if ($this->visibleNotice($actor, $notice)) { $count++; } } return $count; } /** @return array|null */ public function dashboard(User $actor): ?array { $access = app(DocumentManagementAccess::class); if (! $this->available() || ! $this->canEnter($actor)) { return null; } return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor) + $this->portfolioPending($actor), 'unread' => $this->unreadCount($actor)]; } /** @param array $resource * @param list> $links * @param array $documentChanges * @param list $contributors * @param array{independent_required:bool} $policy * @return EditorialPayload */ private function snapshot(array $resource, array $links, array $documentChanges = [], array $contributors = [], array $policy = ['independent_required' => false]): array { $ids = [(string) $resource['document_id']]; $body = is_string($resource['editorial_content'] ?? null) ? json_decode($resource['editorial_content'], true, 512, JSON_THROW_ON_ERROR) : ($resource['editorial_content'] ?? null); if (! empty($body['image_resource_id'])) { $image = DB::table('portal_resources')->where('id', $body['image_resource_id'])->first(); abort_unless($image && app(EditorialReading::class)->publicResources()->where('r.id', $image->id)->exists(), 422, 'Imagem pública indisponível.'); $ids[] = (string) $image->document_id; } foreach ($links as $link) { if ($link['target_type'] === 'document') { $id = DB::table('portal_resources')->where('id', $link['target_key'])->value('document_id'); abort_unless($id !== null, 422, 'Anexo documental inexistente.'); $ids[] = (string) $id; } } $files = array_values(DB::table('documents')->whereIn('id', array_unique($ids))->orderBy('id')->get()->map(function ($document) { if ($document->status === 'active' && $document->malware_scan_status === 'clean' && $document->deleted_at === null) { app(VerifiedDocumentBinary::class)->respond($document); } return Arr::only(get_object_vars($document), ['id', 'file_name', 'media_type', 'size_bytes', 'sha256', 'version_no', 'status', 'deleted_at', 'issuer', 'issued_on', 'rights_status', 'data_classification', 'malware_scan_status', 'malware_scanned_sha256']); })->all()); abort_unless(count($files) === count(array_unique($ids)), 422, 'Documento não encontrado.'); return ['resource' => $resource, 'links' => $links, 'files' => $files, 'document_changes' => $documentChanges, 'contributors' => $contributors, 'policy' => $policy]; } /** @param array $payload */ private function hash(array $payload): string { return hash('sha256', json_encode(Arr::sortRecursive($payload), JSON_THROW_ON_ERROR)); } public function portfolioEnabled(): bool { if (! config('editorial_workflow.portfolio')) { return false; } abort_unless($this->portfolioAvailable(), 503, 'Estrutura editorial da carteira ainda não instalada.'); return true; } public function portfolioAvailable(): bool { return (bool) config('editorial_workflow.portfolio') && $this->available() && Schema::hasColumn('portal_editorial_editions', 'dossier_id'); } public function portfolioPending(User $actor): int { if (! $this->portfolioAvailable() || ! $actor->can('manage-portal') || app(PortalProfileAccess::class)->has($actor, 'auditor')) { return 0; } return DB::table('portal_editorial_editions as e')->join('investment_dossiers as d', 'd.id', '=', 'e.dossier_id')->whereNull('d.deleted_at') ->whereNotIn('e.state', ['published', 'archived'])->whereNotExists(fn ($q) => $q->selectRaw('1')->from('portal_editorial_editions as later')->whereColumn('later.dossier_id', 'e.dossier_id')->whereColumn('later.revision_no', '>', 'e.revision_no'))->count(); } public function portfolioWrites(string $id): bool { if ($this->portfolioEnabled()) { return true; } abort_if(Schema::hasColumn('portal_editorial_editions', 'dossier_id') && DB::table('portal_editorial_editions')->where('dossier_id', $id)->exists(), 503, 'Reactive o circuito editorial para editar esta ficha; a publicação anterior está preservada.'); return false; } public function authorizePortfolio(User $actor, string $id, bool $write = false): void { abort_unless($this->portfolioEnabled(), 503); abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403); abort_unless(DB::table('investment_dossiers')->where('id', $id)->whereNull('deleted_at')->exists(), 404); if ($write) { abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.'); } } public function latestPortfolio(string $id): ?\stdClass { return DB::table('portal_editorial_editions')->where('dossier_id', $id)->orderByDesc('revision_no')->first(); } public function workingPortfolio(InvestmentDossier $record): InvestmentDossier { $edition = $this->latestPortfolio($record->id); if (! $edition) { return $record; } $copy = clone $record; $fields = $this->payload($edition)['resource']; $copy->forceFill(Arr::only($fields, ['title', 'source', 'content'])); $copy->setAttribute('version_no', max((int) $record->getAttribute('version_no'), (int) $edition->revision_no)); return $copy; } public function structureDossier(string $structure, string $id, bool $write = false): ?InvestmentDossier { $foreign = ['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'][$structure] ?? null; if (! $foreign) { return null; } $dossier = ($write ? InvestmentDossier::withTrashed() : InvestmentDossier::query())->where($foreign, $id)->first(); if (! $dossier) { return null; } $enabled = $write ? $this->portfolioWrites($dossier->id) : $this->portfolioAvailable(); abort_if($enabled && $write && $dossier->trashed(), 409, 'A ficha associada está arquivada. Recupere o contexto antes de alterar os dados-base.'); return $enabled ? $dossier : null; } public function workingStructure(Model $record, InvestmentDossier $dossier): Model { $copy = clone $record; $edition = $this->latestPortfolio($dossier->id); if ($edition && $edition->published_at === null) { $intent = $this->payload($edition)['resource']['_structure'] ?? null; if ($intent) { abort_unless($intent['id'] === $record->getKey() && $intent['table'] === $record->getTable(), 409); $copy->forceFill($intent['fields']); } } $copy->setAttribute('editorial_version', (int) $this->workingPortfolio($dossier)->getAttribute('version_no')); return $copy; } /** @param array $changes */ public function saveStructure(User $actor, Model $record, InvestmentDossier $dossier, int $expected, array $changes): \stdClass { $this->authorizePortfolio($actor, $dossier->id, true); return DB::transaction(function () use ($actor, $record, $dossier, $expected, $changes) { $dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail(); $edition = $this->latestPortfolio($dossier->id); $prior = $edition && $edition->published_at === null ? ($this->payload($edition)['resource']['_structure']['fields'] ?? []) : []; $allowed = self::STRUCTURE_FIELDS[$record->getTable()] ?? abort(422); return $this->savePortfolio($actor, $dossier, $expected, ['_structure' => ['table' => $record->getTable(), 'id' => $record->getKey(), 'fields' => array_replace($prior, Arr::only($changes, $allowed))]]); }); } /** @param array $fields * @param list $contributors * @return EditorialPayload */ private function portfolioSnapshot(array $fields, array $contributors): array { if (is_string($fields['content'])) { $fields['content'] = json_decode($fields['content'], true, 512, JSON_THROW_ON_ERROR); } $links = []; foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $key => $table) { if (! empty($fields[$key])) { $context = DB::table($table)->where('id', $fields[$key])->whereNull('deleted_at')->first(); abort_unless($context !== null, 422, 'Contexto da ficha indisponível.'); $links[] = ['target_type' => $table, 'target_key' => $fields[$key], 'context' => Arr::only(get_object_vars($context), ['id', 'name', 'code', 'sector', 'status', 'description', 'primary_territory_id', 'territory_version', 'budget_amount', 'budget_currency', 'start_date', 'end_date'])]; } } abort_unless(count($links) === 1, 422, 'Contexto único da ficha necessário.'); if ($intent = $fields['_structure'] ?? null) { abort_unless($intent['id'] === $links[0]['target_key'] && $intent['table'] === $links[0]['target_type'], 422, 'Alteração de dados-base fora do contexto da ficha.'); abort_unless(array_diff(array_keys($intent['fields']), self::STRUCTURE_FIELDS[$intent['table']]) === [], 422, 'Campos fora do âmbito da estrutura.'); if (array_key_exists('primary_territory_id', $intent['fields'])) { $territory = $intent['fields']['primary_territory_id']; $path = $territory ? app(InvestmentGeography::class)->path($territory, true) : []; abort_if($territory && $path === [], 422, 'Localização proposta indisponível.'); abort_if(count($path) > 4, 422, 'Hierarquia territorial proposta inválida.'); $context = []; foreach ($path as $index => $node) { $location = DB::table('geo_locations')->where('id', $node->id)->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])->first(); abort_unless($location && $location->location_type === ['province', 'district', 'admin_post', 'locality'][$index], 422, 'Hierarquia territorial proposta indisponível.'); $context[] = Arr::only(get_object_vars($location), ['id', 'name', 'parent_id', 'location_type', 'version_no', 'data_classification', 'source_system', 'external_id', 'source_code']); } $fields['_structure']['territory_context'] = $context; } } return ['resource' => $fields, 'links' => $links, 'files' => [], 'document_changes' => [], 'contributors' => $contributors, 'policy' => ['independent_required' => true]]; } /** @param EditorialPayload $payload * @return EditorialPayload */ private function refreshPayload(\stdClass $edition, array $payload): array { return ($edition->dossier_id ?? null) ? $this->portfolioSnapshot($payload['resource'], $payload['contributors']) : $this->snapshot($payload['resource'], $payload['links'], $payload['document_changes'], $payload['contributors'], $payload['policy']); } /** @param array $changes */ public function savePortfolio(User $actor, InvestmentDossier $record, int $expected, array $changes): \stdClass { $this->authorizePortfolio($actor, $record->id, true); return DB::transaction(function () use ($actor, $record, $expected, $changes) { $record = InvestmentDossier::whereKey($record->id)->lockForUpdate()->firstOrFail(); abort_unless((int) $this->workingPortfolio($record)->getAttribute('version_no') === $expected, 409, 'A edição mudou. Recupere a versão actual antes de guardar.'); $previous = $this->latestPortfolio($record->id); $public = $this->portfolioSnapshot($record->getAttributes(), []); if (! $previous && $record->getAttribute('publication_status') === 'published') { DB::table('portal_editorial_editions')->insert(['id' => (string) Str::uuid(), 'dossier_id' => $record->id, 'revision_no' => $expected, 'state' => 'published', 'version' => 1, 'payload' => json_encode($public, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($public), 'author_id' => $record->editor_id ?? $actor->id, 'legal_required' => false, 'published_at' => $record->getAttribute('published_at'), 'created_at' => now(), 'updated_at' => now()]); } $base = $previous ? $this->payload($previous) : $public; if ($previous && $previous->published_at !== null) { unset($base['resource']['_structure']); } $fields = array_replace($base['resource'], Arr::only($changes, ['title', 'source', 'content', '_structure']), ['version_no' => $expected + 1]); $contributors = array_values(array_unique([...($previous && $previous->published_at === null ? $base['contributors'] : []), $actor->id])); $snapshot = $this->portfolioSnapshot($fields, $contributors); $id = (string) Str::uuid(); DB::table('portal_editorial_editions')->insert(['id' => $id, 'dossier_id' => $record->id, 'revision_no' => $expected + 1, 'state' => 'draft', 'version' => 1, 'payload' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($snapshot), 'author_id' => $actor->id, 'legal_required' => false, 'created_at' => now(), 'updated_at' => now()]); $edition = DB::table('portal_editorial_editions')->where('id', $id)->first(); $this->audit($actor, $edition, 'draft_saved', 'Edição privada da carteira guardada; publicação preservada.'); return $edition; }); } /** @param EditorialPayload $payload */ private function publishPortfolio(User $actor, \stdClass $record, \stdClass $edition, array $payload): void { if ($intent = $payload['resource']['_structure'] ?? null) { abort_unless(in_array($intent['table'], ['projects', 'economic_clusters', 'development_corridors'], true), 422); $model = ['projects' => Project::class, 'economic_clusters' => EconomicCluster::class, 'development_corridors' => DevelopmentCorridor::class][$intent['table']]; $target = $model::whereKey($intent['id'])->lockForUpdate()->first(); $territories = array_column($intent['territory_context'] ?? [], 'id'); if ($territories !== []) { DB::table('geo_locations')->whereIn('id', $territories)->orderBy('id')->lockForUpdate()->get(); } abort_unless($target !== null && hash_equals($edition->content_hash, $this->hash($this->refreshPayload($edition, $payload))), 409, 'O contexto foi alterado. Obtenha nova aprovação.'); try { PortalAudit::save($target, $intent['fields'], $actor->id); if (array_key_exists('primary_territory_id', $intent['fields'])) { DB::table('audit_events')->insert(['source_system' => 'investment-territory', 'entity_type' => $intent['table'], 'actor_user_id' => $actor->id, 'action' => 'primary_territory_saved', 'after_data' => json_encode(['record_id' => $intent['id'], 'territory_id' => $intent['fields']['primary_territory_id'], 'version' => $intent['fields']['territory_version']], JSON_THROW_ON_ERROR)]); } } catch (QueryException $exception) { if (in_array($exception->errorInfo[0] ?? '', ['23505', '23514'], true)) { abort(409, 'Os dados-base deixaram de ser aplicáveis ou o código já está em uso. Guarde nova edição antes de publicar.'); } throw $exception; } } $approval = json_decode($edition->approval, true, 512, JSON_THROW_ON_ERROR); $fields = Arr::only($payload['resource'], ['title', 'source', 'content']); $fields['content'] = json_encode($fields['content'], JSON_THROW_ON_ERROR); DB::table('investment_dossiers')->where('id', $record->id)->update([...$fields, 'version_no' => max((int) $record->version_no + 1, (int) $edition->revision_no), 'editor_id' => $edition->author_id, 'reviewer_id' => $approval['actor_id'], 'approval_basis' => 'manager', 'approval_evidence' => $approval['reason'], 'publication_status' => 'published', 'published_at' => now(), 'updated_at' => now()]); } /** @param array $changes * @param list>|null $links */ public function save(User $actor, int $resource, int $expectedVersion, array $changes, ?array $links = null): \stdClass { $this->authorize($actor, $resource, true); return DB::transaction(function () use ($actor, $resource, $expectedVersion, $changes, $links) { $r = DB::table('portal_resources')->where('id', $resource)->lockForUpdate()->first(); abort_unless($r !== null, 404); abort_unless((int) $this->workingResource($r)->metadata_version === $expectedVersion, 409, 'A edição mudou. Recupere a versão actual antes de guardar.'); $previous = $this->latest($resource); if (! $previous && $r->published) { $public = $this->snapshot((array) $r, array_values(DB::table('portal_resource_links')->where('resource_id', $resource)->orderBy('id')->get()->map(fn ($l) => get_object_vars($l))->all())); DB::table('portal_editorial_editions')->insert(['id' => (string) Str::uuid(), 'resource_id' => $resource, 'revision_no' => $expectedVersion, 'state' => 'published', 'version' => 1, 'payload' => json_encode($public, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($public), 'author_id' => $r->created_by ?? $actor->id, 'legal_required' => false, 'created_at' => now(), 'updated_at' => now()]); } $base = $previous ? $this->payload($previous) : ['resource' => (array) $r, 'links' => array_values(DB::table('portal_resource_links')->where('resource_id', $resource)->orderBy('id')->get()->map(fn ($l) => get_object_vars($l))->all())]; $fields = array_replace($base['resource'], Arr::except($changes, ['_document', 'id', 'created_by', 'approved_by', 'published', 'metadata_version', 'created_at', 'updated_at', 'independent_review'])); $fields['metadata_version'] = $expectedVersion + 1; $fields['independent_review'] = false; $contributors = array_values(array_unique([...($previous && $previous->state !== 'published' ? ($base['contributors'] ?? [$previous->author_id]) : []), $actor->id])); $snapshot = $this->snapshot($fields, $links ?? $base['links'], Arr::only($changes['_document'] ?? $base['document_changes'] ?? [], ['issuer', 'issued_on', 'rights_status', 'data_classification']), $contributors, ['independent_required' => $this->independentPolicy($resource, $fields)]); foreach ($snapshot['files'] as $file) { abort_unless($this->canReadFile($actor, $file['id']), 403); } $id = (string) Str::uuid(); DB::table('portal_editorial_editions')->insert(['id' => $id, 'resource_id' => $resource, 'revision_no' => $expectedVersion + 1, 'state' => 'draft', 'version' => 1, 'payload' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($snapshot), 'author_id' => $actor->id, 'legal_required' => (bool) ($r->legislation || ($previous->legal_required ?? false) || ($fields['legislation'] ?? false)) || ($fields['document_type'] ?? '') === 'legislation', 'created_at' => now(), 'updated_at' => now()]); $edition = DB::table('portal_editorial_editions')->where('id', $id)->first(); $this->audit($actor, $edition, 'draft_saved', 'Edição privada guardada; versão pública preservada.'); return $edition; }); } /** @return array */ private function decision(User $actor, \stdClass $edition, string $reason): array { return ['actor_id' => $actor->id, 'edition_id' => $edition->id, 'hash' => $edition->content_hash, 'reason' => $reason, 'at' => now()->toISOString()]; } /** @param array $input */ public function transition(User $actor, int|string $resource, string $id, array $input, bool $portfolio = false): \stdClass { if ($portfolio) { $this->authorizePortfolio($actor, (string) $resource, true); } else { $this->authorize($actor, (int) $resource, true); } $reason = $portfolio && in_array($input['action'] ?? '', ['approve', 'publish'], true) ? 'required|string|min:15|max:3000' : 'required|string|min:3|max:5000'; $data = validator($input, ['version' => 'required|integer|min:1', 'action' => 'required|in:submit,start_review,return,reject,legal_validate,approve,publish,archive', 'reason' => $reason, 'assigned_to' => 'nullable|integer|exists:users,id'])->validate(); return DB::transaction(function () use ($actor, $resource, $id, $data, $portfolio) { $r = DB::table($portfolio ? 'investment_dossiers' : 'portal_resources')->where('id', $resource)->lockForUpdate()->first(); $e = DB::table('portal_editorial_editions')->where('id', $id)->where($portfolio ? 'dossier_id' : 'resource_id', $resource)->lockForUpdate()->first(); abort_unless($r && $e, 404); $latest = $portfolio ? $this->latestPortfolio((string) $resource) : $this->latest((int) $resource); abort_unless((int) $e->version === (int) $data['version'] && $latest?->id === $id, 409, 'Versão desactualizada.'); $action = $data['action']; if (! in_array($action, ['submit', 'archive'], true)) { abort_unless(app(DocumentManagementAccess::class)->full($actor), 403, 'Responsável de revisão/publicação necessário.'); } if ($action === 'archive') { abort_unless((int) $e->author_id === $actor->id || app(DocumentManagementAccess::class)->full($actor), 403); } $allowed = match ($action) { 'submit' => ['draft', 'returned'], 'start_review' => ['submitted'], 'return', 'reject', 'legal_validate', 'approve' => ['in_review'], 'publish' => ['approved'], 'archive' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published'], default => abort(422, 'Operação editorial desconhecida.'), }; abort_unless(in_array($e->state, $allowed, true), 409, 'Transição incompatível com o estado actual.'); $payload = $this->payload($e); // Content is immutable; changed files invalidate the decision rather than being silently accepted. if (in_array($action, ['submit', 'approve', 'legal_validate', 'publish'], true)) { abort_unless(hash_equals($e->content_hash, $this->hash($this->refreshPayload($e, $payload))), 409, 'Conteúdo, contexto ou anexos alterados. Guarde uma nova edição e obtenha nova aprovação.'); } $updates = []; if ((in_array($action, ['approve', 'legal_validate'], true) || ($portfolio && $action === 'publish')) && $this->requiresIndependent($e)) { abort_if(in_array($actor->id, $payload['contributors'], true), 403, 'Esta edição exige revisão independente.'); } if (in_array($action, ['approve', 'legal_validate'], true)) { foreach ($payload['files'] as $file) { abort_unless($file['status'] === 'active' && $file['deleted_at'] === null && $file['malware_scan_status'] === 'clean' && hash_equals(trim($file['sha256']), trim((string) $file['malware_scanned_sha256'])), 422, 'Conclua a análise dos anexos antes da aprovação.'); } } if ($action === 'legal_validate') { abort_unless(in_array($actor->id, config('editorial_workflow.legal_reviewers', []), true), 403, 'Designação jurídica explícita necessária.'); $updates['legal_validation'] = json_encode($this->decision($actor, $e, $data['reason']), JSON_THROW_ON_ERROR); } if ($action === 'approve') { if ($e->legal_required) { $this->assertDecision($e, $e->legal_validation, 'Validação jurídica desta edição necessária.', true); } $updates['approval'] = json_encode($this->decision($actor, $e, $data['reason']), JSON_THROW_ON_ERROR); } if ($action === 'publish') { $this->assertDecision($e, $e->approval, 'Aprovação desta edição necessária.'); if ($e->legal_required) { $this->assertDecision($e, $e->legal_validation, 'Validação jurídica desta edição necessária.', true); } if ($portfolio) { $this->publishPortfolio($actor, $r, $e, $payload); } else { $this->publish($actor, $r, $e, $payload); } $updates['published_at'] = now(); } if ($action === 'submit' && isset($data['assigned_to'])) { $responsible = User::query()->whereKey($data['assigned_to'])->firstOrFail(); abort_unless(app(DocumentManagementAccess::class)->full($responsible) && ! app(PortalProfileAccess::class)->has($responsible, 'auditor'), 422, 'Responsável sem autoridade editorial.'); abort_if($portfolio && ! $responsible->can('manage-portal'), 422, 'Responsável sem autoridade sobre a carteira.'); $updates['assigned_to'] = $responsible->id; } if ($action === 'archive' && $e->state === 'published') { if ($portfolio) { DB::table('investment_dossiers')->where('id', $resource)->update(['publication_status' => 'draft', 'version_no' => (int) $r->version_no + 1, 'updated_at' => now()]); } else { DB::table('portal_resources')->where('id', $resource)->update(['published' => false, 'metadata_version' => (int) $r->metadata_version + 1, 'updated_at' => now()]); } } $updates['state'] = match ($action) { 'submit' => 'submitted', 'start_review', 'legal_validate' => 'in_review', 'return' => 'returned', 'reject' => 'rejected', 'approve' => 'approved', 'publish' => 'published', 'archive' => 'archived', }; $updates['version'] = (int) $e->version + 1; $updates['updated_at'] = now(); DB::table('portal_editorial_editions')->where('id', $id)->update($updates); $this->audit($actor, $e, $action, $data['reason'], $updates['state']); foreach (array_unique(array_filter([$e->author_id, $updates['assigned_to'] ?? $e->assigned_to])) as $recipient) { if ((int) $recipient !== $actor->id) { DB::table('portal_editorial_notifications')->insert(['edition_id' => $id, 'user_id' => $recipient, 'action' => $action, 'created_at' => now()]); } } return DB::table('portal_editorial_editions')->where('id', $id)->first(); }); } private function assertDecision(\stdClass $e, ?string $decision, string $message, bool $legal = false): void { $d = $decision ? json_decode($decision, true, 512, JSON_THROW_ON_ERROR) : []; abort_unless(($d['edition_id'] ?? null) === $e->id && ($d['hash'] ?? null) === $e->content_hash, 422, $message); $actor = User::query()->whereKey($d['actor_id'] ?? 0)->first(); abort_unless($actor && $actor->hasVerifiedEmail() && app(DocumentManagementAccess::class)->full($actor) && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403, 'Autoridade da decisão revogada.'); abort_if(($e->dossier_id ?? null) && ! $actor->can('manage-portal'), 403, 'Autoridade sobre a carteira revogada.'); if ($legal) { abort_unless(in_array($actor->id, config('editorial_workflow.legal_reviewers', []), true), 403, 'Designação jurídica revogada.'); } if ($this->requiresIndependent($e)) { abort_if(in_array($actor->id, $this->payload($e)['contributors'], true), 403, 'A decisão exige aprovação independente.'); } } /** @param array $payload */ private function publish(User $actor, \stdClass $r, \stdClass $e, array $payload): void { $fields = $payload['resource']; foreach ($payload['files'] as $file) { if ($file['id'] === $fields['document_id']) { $file = array_replace($file, $payload['document_changes']); } abort_unless($file['status'] === 'active' && $file['deleted_at'] === null && $file['rights_status'] === 'authorized' && $file['data_classification'] === 'public' && $file['malware_scan_status'] === 'clean' && hash_equals(trim($file['sha256']), trim((string) $file['malware_scanned_sha256'])), 422, 'Anexos públicos, autorizados e verificados necessários.'); } abort_unless(($fields['rights_status'] ?? '') === 'authorized' && ($fields['source_confirmation'] ?? 'unconfirmed') !== 'unconfirmed' && ! empty($fields['source_url']), 422, 'Direitos e fonte necessários.'); abort_unless(! (($fields['legislation'] ?? false) || ($fields['document_type'] ?? '') === 'legislation') || ($fields['source_confirmation'] ?? '') === 'official_confirmed', 422, 'Confirmação da fonte legislativa necessária.'); $changes = $payload['document_changes']; if ($changes !== []) { $document = DB::table('documents')->where('id', $fields['document_id'])->lockForUpdate()->first(); $shared = DB::table('portal_resources')->where('document_id', $document->id)->where('id', '!=', $r->id)->where('published', true)->exists(); foreach ($changes as $key => $value) { abort_if($shared && $value != $document->$key, 422, 'Crie uma nova versão do ficheiro para alterar metadados partilhados com outra publicação.'); } DB::table('documents')->where('id', $document->id)->update([...$changes, 'updated_at' => now()]); } $fields = Arr::except($fields, ['id', 'metadata_version', 'created_at', 'created_by', 'updated_at', 'published', 'approved_by', 'independent_review']); $fields['independent_review'] = ! in_array(json_decode($e->approval, true, 512, JSON_THROW_ON_ERROR)['actor_id'], $payload['contributors'], true); DB::table('portal_resources')->where('id', $r->id)->update([...$fields, 'published' => true, 'metadata_version' => max((int) $r->metadata_version + 1, (int) $e->revision_no), 'approved_by' => json_decode($e->approval, true, 512, JSON_THROW_ON_ERROR)['actor_id'], 'updated_at' => now()]); DB::table('portal_resource_links')->where('resource_id', $r->id)->delete(); foreach ($payload['links'] as $link) { DB::table('portal_resource_links')->insert([...Arr::except($link, ['id', 'resource_id', 'created_at', 'updated_at']), 'resource_id' => $r->id, 'created_at' => now(), 'updated_at' => now()]); } } private function audit(User $actor, \stdClass $e, string $action, string $reason, ?string $nextState = null): void { $current = DB::table('portal_editorial_editions')->where('id', $e->id)->first(); app(DocumentManagementAccess::class)->audit($actor, 'editorial_'.$action, ['resource_id' => $e->resource_id, 'dossier_id' => $e->dossier_id ?? null, 'edition_id' => $e->id, 'hash' => $e->content_hash, 'version' => $e->version, 'from_state' => $e->state, 'to_state' => $nextState ?? $e->state, 'result_version' => $current->version, 'approval' => $current->approval, 'legal_validation' => $current->legal_validation, 'reason' => $reason]); } }