feat(editorial): preserve public portfolio fiches during private review
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\InvestmentDossier;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
@@ -56,7 +57,7 @@ final class PortalEditorialWorkflow
|
||||
{
|
||||
$p = $this->payload($e);
|
||||
try {
|
||||
return hash_equals($e->content_hash, $this->hash($this->snapshot($p['resource'], $p['links'], $p['document_changes'], $p['contributors'], $p['policy'])));
|
||||
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;
|
||||
@@ -154,7 +155,14 @@ final class PortalEditorialWorkflow
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$this->authorize($actor, (int) $edition->resource_id);
|
||||
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) {
|
||||
@@ -193,7 +201,7 @@ final class PortalEditorialWorkflow
|
||||
}
|
||||
$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 : '/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];
|
||||
'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;
|
||||
}
|
||||
@@ -222,7 +230,7 @@ final class PortalEditorialWorkflow
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor), 'unread' => $this->unreadCount($actor)];
|
||||
return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor) + $this->portfolioPending($actor), 'unread' => $this->unreadCount($actor)];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $resource
|
||||
@@ -266,6 +274,141 @@ final class PortalEditorialWorkflow
|
||||
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;
|
||||
}
|
||||
|
||||
/** @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.');
|
||||
|
||||
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<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;
|
||||
$fields = array_replace($base['resource'], Arr::only($changes, ['title', 'source', 'content']), ['version_no' => $expected + 1]);
|
||||
$contributors = array_values(array_unique([...($previous && $previous->state !== 'published' ? $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(\stdClass $record, \stdClass $edition, array $payload): void
|
||||
{
|
||||
$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
|
||||
*/
|
||||
@@ -311,16 +454,22 @@ final class PortalEditorialWorkflow
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function transition(User $actor, int $resource, string $id, array $input): \stdClass
|
||||
public function transition(User $actor, int|string $resource, string $id, array $input, bool $portfolio = false): \stdClass
|
||||
{
|
||||
$this->authorize($actor, $resource, true);
|
||||
$data = validator($input, ['version' => 'required|integer|min:1', 'action' => 'required|in:submit,start_review,return,reject,legal_validate,approve,publish,archive', 'reason' => 'required|string|min:3|max:5000', 'assigned_to' => 'nullable|integer|exists:users,id'])->validate();
|
||||
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) {
|
||||
$r = DB::table('portal_resources')->where('id', $resource)->lockForUpdate()->first();
|
||||
$e = DB::table('portal_editorial_editions')->where('id', $id)->where('resource_id', $resource)->lockForUpdate()->first();
|
||||
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);
|
||||
abort_unless((int) $e->version === (int) $data['version'] && $this->latest($resource)?->id === $id, 409, 'Versão desactualizada.');
|
||||
$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.');
|
||||
@@ -335,11 +484,11 @@ final class PortalEditorialWorkflow
|
||||
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 ($action !== 'archive') {
|
||||
abort_unless(hash_equals($e->content_hash, $this->hash($this->snapshot($payload['resource'], $payload['links'], $payload['document_changes'], $payload['contributors'], $payload['policy']))), 409, 'Conteúdo ou anexos alterados. Guarde uma nova edição e obtenha nova aprovação.');
|
||||
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) && $this->requiresIndependent($e)) {
|
||||
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)) {
|
||||
@@ -362,16 +511,25 @@ final class PortalEditorialWorkflow
|
||||
if ($e->legal_required) {
|
||||
$this->assertDecision($e, $e->legal_validation, 'Validação jurídica desta edição necessária.', true);
|
||||
}
|
||||
$this->publish($actor, $r, $e, $payload);
|
||||
if ($portfolio) {
|
||||
$this->publishPortfolio($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') {
|
||||
DB::table('portal_resources')->where('id', $resource)->update(['published' => false, 'metadata_version' => (int) $r->metadata_version + 1, 'updated_at' => now()]);
|
||||
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',
|
||||
@@ -396,6 +554,7 @@ final class PortalEditorialWorkflow
|
||||
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.');
|
||||
}
|
||||
@@ -438,7 +597,7 @@ final class PortalEditorialWorkflow
|
||||
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, 'edition_id' => $e->id, 'hash' => $e->content_hash, 'version' => $e->version,
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user