445 lines
26 KiB
PHP
445 lines
26 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Opportunity;
|
|
use App\Models\User;
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
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 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->snapshot($p['resource'], $p['links'], $p['document_changes'], $p['contributors'], $p['policy'])));
|
|
} 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 {
|
|
$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);
|
|
|
|
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;
|
|
}
|
|
$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];
|
|
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;
|
|
}
|
|
|
|
return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor), '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));
|
|
}
|
|
|
|
/** @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 $resource, string $id, array $input): \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();
|
|
|
|
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();
|
|
abort_unless($r && $e, 404);
|
|
abort_unless((int) $e->version === (int) $data['version'] && $this->latest($resource)?->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 ($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.');
|
|
}
|
|
$updates = [];
|
|
if (in_array($action, ['approve', 'legal_validate'], true) && $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);
|
|
}
|
|
$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.');
|
|
$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()]);
|
|
}
|
|
$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.');
|
|
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<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, '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]);
|
|
}
|
|
}
|