Files
Investidor-V2/app/Services/PortalEditorialWorkflow.php
T
2026-11-14 11:15:09 +02:00

990 lines
62 KiB
PHP

<?php
namespace App\Services;
use App\Models\DevelopmentCorridor;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\Project;
use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\QueryException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Private immutable content editions; existing resources remain the public projection.
* @phpstan-type EditorialPayload array{resource:array<string,mixed>,links:list<array<string,mixed>>,files:list<array<string,mixed>>,document_changes:array<string,mixed>,contributors:list<int>,policy:array{independent_required:bool}}
*/
final class PortalEditorialWorkflow
{
public function legalReviewer(User $actor): bool
{
return $actor->hasVerifiedEmail() && (in_array($actor->id, config('editorial_workflow.legal_reviewers', []), true)
|| in_array(strtolower($actor->email), config('editorial_workflow.legal_reviewer_emails', []), true));
}
public function latestSubject(string $kind, string $key): ?\stdClass
{
return DB::table('portal_editorial_editions')->where('subject_kind', $kind)->where('subject_key', $key)->orderByDesc('revision_no')->first();
}
public function subjectVersion(string $kind, string $key): int
{
return (int) ($this->latestSubject($kind, $key)->revision_no ?? 1);
}
/** @param array<string,mixed> $fields
* @param list<int> $contributors
* @return EditorialPayload
*/
private function subjectSnapshot(string $kind, string $key, array $fields, array $contributors, bool $newRecord, bool $independent = false, bool $withdraw = false): array
{
$adapter = app(PortalEditorialSubjects::class);
$snapshot = $adapter->snapshot($kind, $key, $fields);
return ['resource' => ['title' => (string) ($fields['name'] ?? $fields['title'] ?? $kind), '_subject' => ['kind' => $kind, 'key' => $key, 'fields' => $fields, 'new_record' => $newRecord, 'withdraw' => $withdraw]],
'links' => [['target_type' => $adapter->definition($kind)['table'], 'target_key' => $key, 'context' => $snapshot['context']]],
'files' => [], 'document_changes' => [], 'contributors' => $contributors, 'policy' => ['independent_required' => $independent || $adapter->independent($kind)]];
}
/** @param array<string,mixed> $changes */
public function saveSubject(User $actor, string $kind, string $key, int $expected, array $changes, bool $newRecord = false, ?bool $withdraw = null): \stdClass
{
$adapter = app(PortalEditorialSubjects::class);
$adapter->authorize($actor, $kind, $key, true);
return DB::transaction(function () use ($actor, $kind, $key, $expected, $changes, $newRecord, $adapter, $withdraw) {
$record = $adapter->lock($kind, $key, $changes);
$previous = $this->latestSubject($kind, $key);
abort_unless($expected === (int) ($previous->revision_no ?? ($newRecord ? 0 : 1)), 409, 'A proposta foi alterada. Conserve os campos e confira a versão actual.');
$allowed = $adapter->definition($kind)['fields'];
abort_unless(array_diff(array_keys($changes), $allowed) === [], 422, 'Campos fora do âmbito do registo.');
if (! $previous && ! $newRecord) {
$baseline = $this->subjectSnapshot($kind, $key, Arr::only(get_object_vars($record), $allowed), [], false);
$public = ($record->data_classification ?? null) === 'public' || ($record->validation_status ?? null) === 'validated';
DB::table('portal_editorial_editions')->insert(['id' => (string) Str::uuid(), 'subject_kind' => $kind, 'subject_key' => $key, 'revision_no' => 1, 'version' => 1,
'state' => $public ? 'published' : 'archived', 'payload' => json_encode($baseline, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($baseline),
'author_id' => $actor->id, 'legal_required' => false, 'published_at' => $public ? now() : null, 'created_at' => now(), 'updated_at' => now()]);
}
$working = $previous && $previous->published_at === null && $previous->state !== 'archived' ? $this->payload($previous) : null;
$fields = array_replace(Arr::only(get_object_vars($record), $allowed), $working['resource']['_subject']['fields'] ?? [], $changes);
$adapter->validate($kind, Arr::except($fields, ['territory_version']));
if (array_key_exists('primary_territory_id', $changes) && ! array_key_exists('territory_version', $changes)) {
$prior = $working['resource']['_subject']['fields'] ?? get_object_vars($record);
if (($prior['primary_territory_id'] ?? null) !== $changes['primary_territory_id']) {
$fields['territory_version'] = (int) ($prior['territory_version'] ?? 0) + 1;
}
}
$contributors = array_values(array_unique([...($working['contributors'] ?? []), $actor->id]));
$snapshot = $this->subjectSnapshot($kind, $key, $fields, $contributors, $newRecord || ($working['resource']['_subject']['new_record'] ?? false), $previous ? $this->requiresIndependent($previous) : false, $withdraw ?? ($working['resource']['_subject']['withdraw'] ?? false));
$id = (string) Str::uuid();
DB::table('portal_editorial_editions')->insert(['id' => $id, 'subject_kind' => $kind, 'subject_key' => $key, 'revision_no' => $expected + 1, 'version' => 1,
'state' => 'draft', '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)->firstOrFail();
$this->audit($actor, $edition, 'draft_saved', 'Proposta privada guardada; registo anterior preservado.');
return $edition;
});
}
public function subjectPending(User $actor): int
{
if (! app(PortalEditorialSubjects::class)->available() || ! $actor->can('manage-portal') || app(PortalProfileAccess::class)->has($actor, 'auditor')) {
return 0;
}
return $this->subjectQueue()->whereNotIn('e.state', ['published', 'archived'])->count();
}
public function subjectQueue(): Builder
{
return DB::table('portal_editorial_editions as e')->whereNotNull('e.subject_kind')
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('portal_editorial_editions as later')->whereColumn('later.subject_kind', 'e.subject_kind')
->whereColumn('later.subject_key', 'e.subject_key')->whereColumn('later.revision_no', '>', 'e.revision_no'));
}
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, 409, 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;
}
if (app(PortalEditorialSubjects::class)->available() && $actor->can('manage-portal')) {
return true;
}
if (app(PortalEditorialNativeNotices::class)->access($actor, 'cluster-competitiveness') || app(PortalEditorialNativeNotices::class)->access($actor, 'territorial-editorial-v1') || app(PortalEditorialNativeNotices::class)->access($actor, 'investment-zones')) {
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->native_event_id ?? null) {
return app(PortalEditorialNativeNotices::class)->visible($actor, $notice);
}
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->subject_kind ?? null) {
if (! app(PortalEditorialSubjects::class)->available()) {
return false;
}
app(PortalEditorialSubjects::class)->authorize($actor, $edition->subject_kind, $edition->subject_key);
} elseif ($edition->demographic_import_id ?? null) {
if (! $this->demographyAvailable() || ! $actor->two_factor_confirmed_at || ! $actor->two_factor_secret) {
return false;
}
$this->authorizeDemography($actor, $edition->demographic_import_id);
} elseif ($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<string,mixed> $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);
if ($edition->subject_kind ?? null) {
return $p['policy']['independent_required'] || app(PortalEditorialSubjects::class)->independent($edition->subject_kind);
}
if ($edition->demographic_import_id ?? null) {
return $p['policy']['independent_required'] || in_array('demography', config('editorial_workflow.independent_approval.kinds', []), true);
}
return $p['policy']['independent_required'] || $this->independentPolicy((int) $edition->resource_id, $p['resource']);
}
/** @return list<array<string,mixed>> */
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;
}
if ($notice->native_event_id ?? null) {
$result[] = app(PortalEditorialNativeNotices::class)->present($notice);
if (count($result) === 50) {
break;
}
continue;
}
$edition = $notice->edition_id ? DB::table('portal_editorial_editions')->where('id', $notice->edition_id)->first() : null;
$subjectHref = ($edition->subject_kind ?? null) ? '/management/editorial/subjects/'.$edition->subject_kind.'/'.$edition->subject_key.'?edition='.$edition->id : null;
$result[] = ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at,
'href' => $subjectHref ?? ($notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : (($edition->demographic_import_id ?? null) ? '/management/editorial/demography/'.$edition->demographic_import_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<string,int>|null */
public function dashboard(User $actor): ?array
{
$access = app(DocumentManagementAccess::class);
if (! $this->available() || ! $this->canEnter($actor)) {
return null;
}
$native = app(PortalEditorialNativeNotices::class)->pending($actor);
return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor) + $this->portfolioPending($actor) + $this->demographyPending($actor) + $this->subjectPending($actor) + $native['clusters'] + $native['territories'] + $native['zones'], 'unread' => $this->unreadCount($actor)];
}
/** @param array<string,mixed> $resource
* @param list<array<string,mixed>> $links
* @param array<string,mixed> $documentChanges
* @param list<int> $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<string,mixed> $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<string,mixed> $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<string,mixed> $fields
* @param list<int> $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
{
if ($edition->subject_kind ?? null) {
$subject = $payload['resource']['_subject'];
return $this->subjectSnapshot($edition->subject_kind, $edition->subject_key, $subject['fields'], $payload['contributors'], $subject['new_record'] ?? false, $payload['policy']['independent_required'], $subject['withdraw'] ?? false);
}
if ($edition->demographic_import_id ?? null) {
return $this->demographySnapshot($edition->demographic_import_id, $payload['contributors'], $payload['policy']);
}
return ($edition->dossier_id ?? null) ? $this->portfolioSnapshot($payload['resource'], $payload['contributors'])
: $this->snapshot($payload['resource'], $payload['links'], $payload['document_changes'], $payload['contributors'], $payload['policy']);
}
public function demographyAvailable(): bool
{
return (bool) config('editorial_workflow.demography') && $this->available() && Schema::hasColumn('portal_editorial_editions', 'demographic_import_id');
}
public function demographyWrites(string $id): bool
{
if (config('editorial_workflow.demography')) {
abort_unless($this->demographyAvailable(), 503, 'Estrutura editorial da demografia ainda não instalada.');
return true;
}
abort_if(Schema::hasColumn('portal_editorial_editions', 'demographic_import_id') && DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->exists(), 503, 'Reactive o circuito editorial para disponibilizar este lote.');
return false;
}
public function demographyAccess(User $actor): bool
{
if (! $this->demographyAvailable() || ! $actor->can('manage-portal') || app(PortalProfileAccess::class)->has($actor, 'auditor')) {
return false;
}
try {
app(DemographicImport::class)->authorize($actor);
return true;
} catch (HttpException $exception) {
if ($exception->getStatusCode() === 403) {
return false;
}
throw $exception;
}
}
public function demographyPending(User $actor): int
{
if (! $this->demographyAccess($actor)) {
return 0;
}
return DB::table('portal_editorial_editions as e')->whereNotNull('e.demographic_import_id')->whereNotIn('e.state', ['published', 'archived'])
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('portal_editorial_editions as later')->whereColumn('later.demographic_import_id', 'e.demographic_import_id')->whereColumn('later.revision_no', '>', 'e.revision_no'))->count();
}
public function authorizeDemography(User $actor, string $id, bool $write = false): void
{
abort_unless($this->demographyAvailable(), 503);
abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
app(DemographicImport::class)->authorize($actor);
abort_unless(DB::table('demographic_imports')->where('id', $id)->exists(), 404);
if ($write) {
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
}
}
public function latestDemography(string $id): ?\stdClass
{
return DB::table('portal_editorial_editions')->where('demographic_import_id', $id)->orderByDesc('revision_no')->first();
}
/** Bounded bucket aggregates: never load the observation dataset into PHP memory.
* @param list<int> $contributors
* @param array{independent_required:bool} $policy
* @return EditorialPayload
*/
private function demographySnapshot(string $id, array $contributors, array $policy): array
{
$batch = DB::table('demographic_imports')->where('id', $id)->first();
abort_unless($batch && $batch->status !== 'prepared', 422, 'Conclua primeiro a importação privada e as correspondências territoriais.');
$path = Storage::disk('local')->path('demography/'.$id.'/original.xlsx');
$preview = Storage::disk('local')->path('demography/'.$id.'/preview.json');
abort_unless(CheckedFile::matches($path, trim($batch->sha256)) && CheckedFile::matches($preview, trim($batch->preview_sha256)), 409, 'O original ou a prévia mudou. Conserve-os e prepare uma nova edição.');
$report = json_decode($batch->report, true, 512, JSON_THROW_ON_ERROR);
abort_unless(($report['scan']['result'] ?? '') === 'clean' && ($report['scan']['sha256'] ?? '') === trim($batch->sha256), 422, 'Original com análise antimalware limpa e vinculada ao hash necessário.');
$buckets = DB::select("SELECT left(md5(o.id::text),2) AS bucket, count(*) AS records, encode(sha256(convert_to(string_agg(encode(sha256(convert_to(to_jsonb(o)::text,'UTF8')),'hex'),'' ORDER BY o.id),'UTF8')),'hex') AS digest FROM demographic_observations o WHERE o.import_id=? GROUP BY left(md5(o.id::text),2) ORDER BY bucket", [$id]);
$count = array_sum(array_map(fn ($bucket) => (int) $bucket->records, $buckets));
abort_unless($count > 0 && $count === (int) ($report['record_count'] ?? -1), 422, 'Contagem do lote diferente da importação conferida.');
$territories = DB::table('demographic_observations')->where('import_id', $id)->distinct()->pluck('geo_location_id');
$contexts = DB::table('geo_locations')->whereIn('id', $territories)->orderBy('id')->get(['id', 'name', 'parent_id', 'location_type', 'version_no', 'data_classification', 'deleted_at']);
abort_unless($contexts->count() === $territories->count() && $contexts->every(fn ($row) => $row->deleted_at === null), 422, 'Correspondência territorial do lote indisponível.');
$fields = ['id' => $id, 'title' => $batch->file_name, 'source' => $batch->source, 'publication_version' => $batch->publication_version,
'sha256' => trim($batch->sha256), 'preview_sha256' => trim($batch->preview_sha256), 'report' => $report, 'observations' => $count,
'dataset_hash' => $this->hash(['buckets' => array_map(fn ($bucket) => get_object_vars($bucket), $buckets)])];
return ['resource' => $fields, 'links' => array_values($contexts->map(fn ($row) => ['target_type' => 'territory', 'target_key' => $row->id, 'context' => get_object_vars($row)])->all()), 'files' => [], 'document_changes' => [], 'contributors' => $contributors, 'policy' => $policy];
}
public function saveDemography(User $actor, string $id, int $expected): \stdClass
{
$this->authorizeDemography($actor, $id, true);
return DB::transaction(function () use ($actor, $id, $expected) {
$batch = DB::table('demographic_imports')->where('id', $id)->lockForUpdate()->first();
abort_unless($batch && in_array($batch->status, ['imported', 'disabled'], true), 409, 'O lote já disponibilizado é preservado. Uma nova fonte deve ser um lote próprio.');
$previous = $this->latestDemography($id);
abort_unless((int) ($previous->revision_no ?? 0) === $expected, 409, 'A edição do lote mudou. Recupere a versão actual.');
$contributors = array_values(array_unique([$actor->id, (int) $batch->prepared_by]));
$snapshot = $this->demographySnapshot($id, $contributors, ['independent_required' => in_array('demography', config('editorial_workflow.independent_approval.kinds', []), true) || ($previous ? $this->requiresIndependent($previous) : false)]);
if ($previous) {
abort_unless(hash_equals($this->hash(Arr::except($this->payload($previous)['resource'], ['status'])), $this->hash(Arr::except($snapshot['resource'], ['status']))), 409, 'O original, as observações ou a proveniência do lote mudaram. Preserve-os; não confirme alterações silenciosas como nova revisão.');
}
$edition = (string) Str::uuid();
DB::table('portal_editorial_editions')->insert(['id' => $edition, 'demographic_import_id' => $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()]);
$record = DB::table('portal_editorial_editions')->where('id', $edition)->first();
$this->audit($actor, $record, 'draft_saved', 'Revisão privada do lote preparada; observações e lotes anteriores preservados.');
return $record;
});
}
/** @param EditorialPayload $payload */
private function publishDemography(User $actor, \stdClass $batch, \stdClass $edition, array $payload, string $reason): void
{
abort_unless(in_array($batch->status, ['imported', 'disabled'], true), 409, 'Estado de disponibilização do lote alterado.');
DB::table('demographic_observations')->where('import_id', $batch->id)->orderBy('id')->lockForUpdate()->lazyById(1000, 'id')->each(static fn (\stdClass $row) => null);
DB::table('geo_locations')->whereIn('id', array_column($payload['links'], 'target_key'))->orderBy('id')->lockForUpdate()->get(['id']);
abort_unless(hash_equals($edition->content_hash, $this->hash($this->refreshPayload($edition, $payload))), 409, 'Dados ou correspondências alterados. Obtenha nova aprovação.');
$this->demographyStatus($actor, $batch, 'approved', $reason, $edition->id);
}
private function demographyStatus(User $actor, \stdClass $batch, string $status, string $reason, string $edition): void
{
DB::table('demographic_imports')->where('id', $batch->id)->update(['status' => $status, 'updated_at' => now()]);
DB::table('demographic_events')->insert(['import_id' => $batch->id, 'actor_id' => $actor->id, 'action' => $status, 'reason' => $reason,
'details' => json_encode(['previous_status' => $batch->status, 'edition_id' => $edition], JSON_THROW_ON_ERROR), 'created_at' => now()]);
}
/** @param array<string,mixed> $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<string,mixed> $changes
* @param list<array<string,mixed>>|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<string,mixed> */
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<string,mixed> $input */
public function transition(User $actor, int|string $resource, string $id, array $input, bool $portfolio = false, bool $demography = false, ?string $subject = null): \stdClass
{
abort_if(count(array_filter([$portfolio, $demography, $subject !== null])) > 1, 422);
if ($subject !== null) {
app(PortalEditorialSubjects::class)->authorize($actor, $subject, (string) $resource, true);
} elseif ($demography) {
$this->authorizeDemography($actor, (string) $resource, true);
} elseif ($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';
if ($demography) {
$reason = 'required|string|min:10|max:2000';
}
$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, $demography, $subject) {
if ($subject !== null) {
$candidate = DB::table('portal_editorial_editions')->where('subject_kind', $subject)->where('subject_key', $resource)->where('id', $id)->first();
abort_unless($candidate !== null, 404);
$r = app(PortalEditorialSubjects::class)->lock($subject, (string) $resource, $this->payload($candidate)['resource']['_subject']['fields']);
$e = DB::table('portal_editorial_editions')->where('id', $id)->lockForUpdate()->first();
} else {
$r = DB::table($demography ? 'demographic_imports' : ($portfolio ? 'investment_dossiers' : 'portal_resources'))->where('id', $resource)->lockForUpdate()->first();
$e = DB::table('portal_editorial_editions')->where('id', $id)->where($demography ? 'demographic_import_id' : ($portfolio ? 'dossier_id' : 'resource_id'), $resource)->lockForUpdate()->first();
}
abort_unless($r && $e, 404);
$latest = $subject !== null ? $this->latestSubject($subject, (string) $resource) : ($demography ? $this->latestDemography((string) $resource) : ($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($subject !== null ? $actor->can('manage-portal') : 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 || ($subject !== null ? $actor->can('manage-portal') : 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 || $subject !== null) && $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($this->legalReviewer($actor), 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 ($subject !== null) {
app(PortalEditorialSubjects::class)->publish($actor, $r, $e, $payload);
} elseif ($demography) {
$this->publishDemography($actor, $r, $e, $payload, $data['reason']);
} elseif ($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(($subject !== null ? $responsible->can('manage-portal') : 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 ($subject !== null) {
app(PortalEditorialSubjects::class)->archive($actor, $r, $e);
} elseif ($demography) {
$this->demographyStatus($actor, $r, 'disabled', $data['reason'], $e->id);
} elseif ($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() && (($e->subject_kind ?? null) ? $actor->can('manage-portal') : 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($this->legalReviewer($actor), 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<string,mixed> $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, 'demographic_import_id' => $e->demographic_import_id ?? null, 'subject_kind' => $e->subject_kind ?? null, 'subject_key' => $e->subject_key ?? 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]);
}
}