diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 6cc2fec2..8450853e 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -7,6 +7,7 @@ use App\Models\Opportunity; use App\Models\Sector; use App\Services\DashboardWorkQueue; use App\Services\InvestorPath; +use App\Services\PortalEditorialWorkflow; use App\Services\PortalProfileAccess; use Illuminate\Http\Request; use Illuminate\Support\Facades\Schema; @@ -28,7 +29,7 @@ class DashboardController extends Controller $contexts = $path->contexts($request->user()); $current = $contexts ? $path->view($request->user(), $contexts[0]['id']) : null; - return Inertia::render('dashboard', ['summary' => null, 'workQueue' => $workQueue, 'investorPath' => $path->available() ? [ + return Inertia::render('dashboard', ['editorialQueue' => app(PortalEditorialWorkflow::class)->dashboard($request->user()), 'summary' => null, 'workQueue' => $workQueue, 'investorPath' => $path->available() ? [ 'contexts' => $contexts, 'current' => $current ? ['id' => $current['case']['id'], 'next' => $current['next'], 'pending' => count(array_filter($current['steps'], fn ($step) => ! in_array($step['state'], ['completed', 'approved', 'dispensed'], true))), @@ -43,7 +44,7 @@ class DashboardController extends Controller $drafts = app(PortalProfileAccess::class)->scope($drafts, $request->user()); } - return Inertia::render('dashboard', ['workQueue' => $workQueue, 'summary' => [ + return Inertia::render('dashboard', ['editorialQueue' => app(PortalEditorialWorkflow::class)->dashboard($request->user()), 'workQueue' => $workQueue, 'summary' => [ 'canCreateOpportunity' => app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()), 'requestsByStatus' => collect(['received', 'in_review', 'contacted', 'closed']) ->mapWithKeys(fn ($status) => [$status => (int) ($counts[$status] ?? 0)]), diff --git a/app/Http/Controllers/EditorialArticleController.php b/app/Http/Controllers/EditorialArticleController.php index 9729b891..84b9b6a1 100644 --- a/app/Http/Controllers/EditorialArticleController.php +++ b/app/Http/Controllers/EditorialArticleController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Services\DocumentManagementAccess; use App\Services\EditorialInstallation; use App\Services\EditorialReading; +use App\Services\PortalEditorialWorkflow; use App\Services\PortalProfileAccess; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Database\Query\Builder; @@ -97,11 +98,17 @@ class EditorialArticleController extends Controller app(DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id); $r = DB::table('portal_resources')->where('id', $id)->first(); abort_unless($r instanceof \stdClass, 404); + $workflow = app(PortalEditorialWorkflow::class); + if ($workflow->enabled()) { + $workflow->authorize(request()->user(), $id); + $r = $workflow->workingResource($r); + } $reading = app(EditorialReading::class); $original = $reading->original($r); $content = $reading->content($r, $original); return tap(Inertia::render('portal/article-editor', ['resource' => ['id' => $r->id, 'title' => $r->title, 'metadata_version' => $r->metadata_version, 'published' => (bool) $r->published], + 'workflowEnabled' => $workflow->enabled(), 'workflow' => $workflow->latest($id), 'content' => $content, 'source' => $original ? array_intersect_key($original, array_flip(['source_url', 'published_at', 'modified_at', 'legacy_id', 'image_source_url'])) : null, 'images' => $this->publicResources()->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->get(['r.id', 'r.title']), 'revisions' => DB::table('portal_resource_revisions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['revision_no', 'change_type', 'created_at'])])->toResponse(request()), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store')); } @@ -114,7 +121,9 @@ class EditorialArticleController extends Controller $r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first(); abort_unless($r !== null, 404); app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); - abort_unless((int) $r->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.'); + $workflow = app(PortalEditorialWorkflow::class); + $current = $workflow->enabled() ? $workflow->workingResource($r) : $r; + abort_unless((int) $current->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.'); if (! empty($data['image_resource_id'])) { abort_unless($this->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.'); } @@ -122,7 +131,13 @@ class EditorialArticleController extends Controller if ($body === '') { throw ValidationException::withMessages(['body' => 'Escreva o corpo do artigo.']); } - $content = $this->edition($r, $data); + $workflow = app(PortalEditorialWorkflow::class); + $content = $this->edition($workflow->enabled() ? $workflow->workingResource($r) : $r, $data); + if ($workflow->enabled()) { + $workflow->save($request->user(), $id, (int) $data['metadata_version'], ['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR)]); + + return; + } $revision = (int) $r->metadata_version + 1; DB::table('portal_resources')->where('id', $id)->update(['editorial_content' => json_encode($content, JSON_THROW_ON_ERROR), 'published' => false, 'approved_by' => null, 'metadata_version' => $revision, 'last_reviewed_by' => $request->user()->id, 'last_reviewed_at' => now(), 'updated_at' => now()]); $resource = DB::table('portal_resources')->where('id', $id)->first(); @@ -165,6 +180,11 @@ class EditorialArticleController extends Controller app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); $resource = DB::table('portal_resources')->where('id', $id)->first(); abort_unless($resource instanceof \stdClass, 404); + $workflow = app(PortalEditorialWorkflow::class); + if ($workflow->enabled()) { + $workflow->authorize(request()->user(), $id); + $resource = $workflow->workingResource($resource); + } return tap(Inertia::render('portal/article', ['article' => app(EditorialReading::class)->reading($resource, null, true)]) ->toResponse($request), static function (Response $response): void { @@ -179,6 +199,11 @@ class EditorialArticleController extends Controller app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); $resource = DB::table('portal_resources')->where('id', $id)->first(); abort_unless($resource instanceof \stdClass, 404); + $workflow = app(PortalEditorialWorkflow::class); + if ($workflow->enabled()) { + $workflow->authorize($request->user(), $id); + $resource = $workflow->workingResource($resource); + } $data = $this->validateContent($request); abort_unless((int) $resource->metadata_version === (int) $data['metadata_version'], 409, 'A revisão mudou; recupere antes de rever a prévia.'); diff --git a/app/Http/Controllers/PortalEditorialWorkflowController.php b/app/Http/Controllers/PortalEditorialWorkflowController.php new file mode 100644 index 00000000..12e01942 --- /dev/null +++ b/app/Http/Controllers/PortalEditorialWorkflowController.php @@ -0,0 +1,118 @@ +'resource'->>'legislation' = 'true' OR e.payload->'resource'->>'document_type' = 'legislation' THEN 'legislation' WHEN e.payload->'resource'->>'editorial_content' IS NOT NULL THEN 'article' ELSE 'document' END"; + + /** @param array $data */ + private function screen(Request $request, string $component, array $data): Response + { + $response = $request->expectsJson() ? response()->json($data) : Inertia::render($component, $data)->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store'); + + return $response; + } + + public function index(Request $request, PortalEditorialWorkflow $workflow): Response + { + abort_unless($workflow->enabled(), 503); + $actor = $request->user(); + $access = app(DocumentManagementAccess::class); + $query = $workflow->queue($actor); + $filters = array_filter($request->validate(['state' => 'nullable|in:draft,submitted,in_review,approved,published,returned,rejected,archived', 'kind' => 'nullable|in:article,legislation,document']), fn ($value) => $value !== null && $value !== ''); + if (isset($filters['state'])) { + $query->where('e.state', $filters['state']); + } + if (isset($filters['kind'])) { + $query->whereRaw(self::KIND.' = ?', [$filters['kind']]); + } + $items = $query->orderByDesc('e.updated_at')->paginate(25, ['r.id', DB::raw("e.payload->'resource'->>'title' AS title"), 'r.document_type', 'r.published', 'e.id as edition_id', 'e.revision_no', 'e.state', 'e.legal_required', 'u.name as responsible'])->withQueryString(); + $opportunities = app(ContentManagement::class)->access($actor)['opportunities'] ?? null; + $data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $opportunities['index'] ?? null, 'opportunityPending' => $workflow->opportunityPending($actor)]; + + return $this->screen($request, 'management/editorial-workflow', $data); + } + + public function show(Request $request, int $id, PortalEditorialWorkflow $workflow): Response + { + $workflow->authorize($request->user(), $id); + $resource = DB::table('portal_resources')->where('id', $id)->first(); + abort_unless($resource !== null, 404); + $selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null; + $latest = $workflow->latest($id); + $e = $selected ? DB::table('portal_editorial_editions')->where('resource_id', $id)->where('id', $selected)->first() : $latest; + abort_if($selected && ! $e, 404); + $payload = $e ? $workflow->payload($e) : null; + $independent = ! $e || ! $workflow->requiresIndependent($e) || ! in_array($request->user()->id, $payload['contributors'], true); + $data = ['resource' => ['id' => $id, 'title' => $resource->title, 'published' => (bool) $resource->published, 'public_edition_no' => $resource->published ? DB::table('portal_editorial_editions')->where('resource_id', $id)->where('state', 'published')->max('revision_no') : null], 'edition' => $e ? [...(array) $e, 'payload' => $payload, 'fresh' => $e->state === 'published' || $workflow->fresh($e)] : null, + 'current' => $e && $e->id === $latest?->id, + 'independentRequired' => $e ? $workflow->requiresIndependent($e) : false, + 'canApprove' => $independent && app(DocumentManagementAccess::class)->full($request->user()), + 'manager' => app(DocumentManagementAccess::class)->full($request->user()), + 'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']), + 'legalReviewer' => in_array($request->user()->id, config('editorial_workflow.legal_reviewers', []), true), + 'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->resource_id', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']), + 'editions' => DB::table('portal_editorial_editions')->where('resource_id', $id)->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]; + + return $this->screen($request, 'management/editorial-edition', $data); + } + + public function transition(Request $request, int $id, string $edition, PortalEditorialWorkflow $workflow): Response + { + $result = $workflow->transition($request->user(), $id, $edition, $request->all()); + + return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial registada.'); + } + + public function file(Request $request, int $id, string $edition, string $document, PortalEditorialWorkflow $workflow): Response + { + $workflow->authorize($request->user(), $id); + $e = DB::table('portal_editorial_editions')->where('id', $edition)->where('resource_id', $id)->first(); + abort_unless($e !== null, 404); + $file = null; + foreach ($workflow->payload($e)['files'] as $entry) { + if ($entry['id'] === $document) { + $file = $entry; + break; + } + } + abort_unless($file !== null, 404); + abort_unless($workflow->canReadFile($request->user(), $document), 403); + $record = DB::table('documents')->where('id', $document)->first(); + abort_unless($record && hash_equals(trim($file['sha256']), trim($record->sha256)) && (int) $file['version_no'] === (int) $record->version_no, 409, 'O anexo mudou; esta edição conserva a referência anterior.'); + + return $this->binary($record); + } + + private function binary(\stdClass $record): Response + { + $response = app(VerifiedDocumentBinary::class)->respond($record); + $response->headers->set('Cache-Control', 'private, no-store'); + $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); + + return $response; + } + + public function markRead(Request $request, int $notice, PortalEditorialWorkflow $workflow): Response + { + abort_unless($workflow->enabled(), 503); + $n = DB::table('portal_editorial_notifications')->where('id', $notice)->where('user_id', $request->user()->id)->first(); + abort_unless($n !== null, 404); + abort_unless($workflow->visibleNotice($request->user(), $n), 404); + DB::table('portal_editorial_notifications')->where('id', $notice)->where('user_id', $request->user()->id)->whereNull('read_at')->update(['read_at' => now()]); + + return $request->expectsJson() ? response()->json(['read' => true]) : back(); + } +} diff --git a/app/Http/Controllers/PortalServicesController.php b/app/Http/Controllers/PortalServicesController.php index 51b6c1b9..ce46c80d 100644 --- a/app/Http/Controllers/PortalServicesController.php +++ b/app/Http/Controllers/PortalServicesController.php @@ -15,6 +15,7 @@ use App\Services\InterestSubmission; use App\Services\InvestmentGeography; use App\Services\LegacyContent; use App\Services\Malware\MalwareScanner; +use App\Services\PortalEditorialWorkflow; use App\Services\PortalProfileAccess; use App\Services\PublicDocumentPresentation; use App\Services\PublicSearchMetrics; @@ -320,6 +321,9 @@ class PortalServicesController extends Controller $editorial = $access->editorial($request->user()); $resources = $editorial ? $this->filterManagedResources($access->editorialResources($request->user()), $managementFilters)->orderByDesc('r.updated_at') ->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']) : collect(); + if (app(PortalEditorialWorkflow::class)->enabled()) { + $resources = $resources->map(fn ($r) => app(PortalEditorialWorkflow::class)->workingResource($r)); + } $links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id'); $revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id'); $resources->each(function ($resource) use ($links, $revisions) { @@ -328,7 +332,7 @@ class PortalServicesController extends Controller }); return Inertia::render('portal/manage', [ - 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request), + 'editorialWorkflowEnabled' => app(PortalEditorialWorkflow::class)->enabled(), 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request), 'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [], 'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false, 'disclosureAuthorization' => null, 'incompleteCount' => $resources->filter(fn ($r) => ! $r->description || ! $r->issuer || ! $r->source_url || $r->source_confirmation === 'unconfirmed' || $r->legal_status === 'unknown' || $r->rights_status === 'unconfirmed')->count(), 'possibleDuplicates' => $resources->whereNotNull('duplicate_of_resource_id')->count(), @@ -353,12 +357,15 @@ class PortalServicesController extends Controller $resources->each(fn ($resource) => $resource->links = $links->get($resource->id, collect())->values()); $revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id'); $resources->each(fn ($resource) => $resource->revisions = $revisions->get($resource->id, collect())->values()); + if (app(PortalEditorialWorkflow::class)->enabled()) { + $resources = $resources->map(fn ($r) => app(PortalEditorialWorkflow::class)->workingResource($r)); + } $malwareHealth = $scanner->health(); $malwareHealth['signatures_at'] = $malwareHealth['signatures_at']?->format(DATE_ATOM); return Inertia::render('portal/manage', ['resources' => $resources, - 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request), + 'editorialWorkflowEnabled' => app(PortalEditorialWorkflow::class)->enabled(), 'managementFilters' => $managementFilters, 'associationOptions' => $this->contentAssociations($request), 'requirementCatalogue' => $this->requirementEvidenceEnabled() ? collect(is_array($catalogue = config('document-requirements.catalogue', [])) ? $catalogue : [])->map(fn ($item, $key) => ['key' => $key, ...$item])->values() : collect(), @@ -474,9 +481,15 @@ class PortalServicesController extends Controller $duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->value('id') : null; $fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, true); $this->assertPublicClassificationEligible($data, $document); + $workflow = app(PortalEditorialWorkflow::class); $id = DB::table('portal_resources')->insertGetId([...$fields, 'approved_by' => null, 'published' => false, 'created_at' => now(), 'updated_at' => now()]); $this->syncResourceLinks($id, $data['associations'] ?? []); $this->recordResourceRevision($id, 'created', $request->user()->id, 1); + if ($workflow->enabled()) { + $workflow->save($request->user(), $id, 1, ['_document' => ['issuer' => ($data['issuer'] ?? null) ?: $document->issuer, 'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'], 'data_classification' => $data['data_classification'] ?? $document->data_classification]]); + + return $id; + } DB::table('documents')->where('id', $document->id)->update(['issuer' => ($data['issuer'] ?? null) ?: $document->issuer, 'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'], 'data_classification' => $data['data_classification'] ?? $document->data_classification, 'updated_at' => now()]); @@ -496,7 +509,9 @@ class PortalServicesController extends Controller $resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first(); abort_unless($resource !== null, 404); app(DocumentManagementAccess::class)->assertEditableResource($request->user(), $id); - abort_if((int) $resource->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.'); + $workflow = app(PortalEditorialWorkflow::class); + $current = $workflow->enabled() ? $workflow->workingResource($resource) : $resource; + abort_if((int) $current->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.'); $document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first(); abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.'); abort_unless(app(DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403); @@ -507,6 +522,12 @@ class PortalServicesController extends Controller $duplicate = $identity ? DB::table('portal_resources')->where('document_identity_key', $identity)->where('id', '!=', $id)->value('id') : null; $fields = $this->resourceFields($data, $request->user()->id, $identity, $duplicate, false); $this->assertPublicClassificationEligible($data, $document); + $workflow = app(PortalEditorialWorkflow::class); + if ($workflow->enabled()) { + $workflow->save($request->user(), $id, $expectedVersion, [...$fields, '_document' => ['issuer' => ($data['issuer'] ?? null) ?: $document->issuer, 'issued_on' => ($data['document_date'] ?? null) ?: $document->issued_on, 'rights_status' => $data['rights_status'], 'data_classification' => $data['data_classification'] ?? $document->data_classification]], $data['associations'] ?? []); + + return; + } $retainPublication = (bool) $resource->published && $data['rights_status'] === 'authorized' && $data['source_confirmation'] !== 'unconfirmed' && ! empty($data['source_url']) && (! ($data['legislation'] || $data['document_type'] === 'legislation') || $data['source_confirmation'] === 'official_confirmed') @@ -523,7 +544,7 @@ class PortalServicesController extends Controller $this->audit($request, 'resource_metadata_updated', 'portal_resources', $id); }); - return to_route('services.manage')->with('success', 'Metadados e relações actualizados; o estado de publicação foi preservado.'); + return to_route('services.manage')->with('success', 'Edição guardada; confira o circuito editorial quando estiver activo. A versão pública é preservada nesse circuito.'); } public function moderate(Request $request, string $type, int $id): RedirectResponse @@ -537,6 +558,12 @@ class PortalServicesController extends Controller return redirect('/parcerias/'.$id)->with('success', 'Reveja a proposta e registe uma decisão fundamentada na ficha da Bolsa.'); } $data = $this->validateManagement($request, ['published' => ['required', 'boolean']]); + if ($type === 'resources' && app(PortalEditorialWorkflow::class)->enabled()) { + app(PortalEditorialWorkflow::class)->authorize($request->user(), $id, true); + } + if ($type === 'resources' && $data['published'] && app(PortalEditorialWorkflow::class)->enabled()) { + abort(409, 'Use a aprovação e publicação explícitas da edição no circuito editorial.'); + } $table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships'; DB::transaction(function () use ($request, $data, $table, $type, $id) { abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404); @@ -582,6 +609,7 @@ class PortalServicesController extends Controller public function applyDocumentDisclosureAuthorization(Request $request): RedirectResponse { + abort_if(app(PortalEditorialWorkflow::class)->enabled(), 409, 'A divulgação de novas edições requer aprovação e publicação no circuito editorial.'); $event = DB::table('audit_events')->where('source_system', 'user-instruction') ->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1') ->where('action', 'public_disclosure_authorized')->first(); diff --git a/app/Services/ContentManagement.php b/app/Services/ContentManagement.php index 44181153..e0b7f532 100644 --- a/app/Services/ContentManagement.php +++ b/app/Services/ContentManagement.php @@ -35,6 +35,10 @@ final class ContentManagement $result['documents'] = ['index' => '/management/portal-services', 'edit' => $editor]; } + if (($editor || isset($result['opportunities'])) && ! $profiles->has($actor, 'auditor') && app(PortalEditorialWorkflow::class)->available()) { + $result['editorial'] = ['index' => '/management/editorial']; + } + return $result; } diff --git a/app/Services/OpportunityCanonicalContract.php b/app/Services/OpportunityCanonicalContract.php index 04efdc1f..12efeef4 100644 --- a/app/Services/OpportunityCanonicalContract.php +++ b/app/Services/OpportunityCanonicalContract.php @@ -308,6 +308,9 @@ class OpportunityCanonicalContract app(OpportunityLibrarySupport::class)->assertAvailable($row, $file); app(VerifiedDocumentBinary::class)->respond($file); $row->sha256 = trim($file->sha256); + if (data_get($record->workflow_context, 'editorial_version_binding') === 'phase1-exact-v1') { + $row->file_version_no = (int) $file->version_no; + } } } else { $location = $row->payload; diff --git a/app/Services/OpportunityProfileWorkflow.php b/app/Services/OpportunityProfileWorkflow.php index d17cff9d..4984042c 100644 --- a/app/Services/OpportunityProfileWorkflow.php +++ b/app/Services/OpportunityProfileWorkflow.php @@ -178,6 +178,11 @@ class OpportunityProfileWorkflow abort_unless(hash_equals($this->management->etag($record), $data['etag']), 409, 'Versão alterada.'); $this->authorizeAction($actor, $record, $action); $context = $record->workflow_context ?? []; + if ($action === 'submit' && app(PortalEditorialWorkflow::class)->available()) { + $context['editorial_version_binding'] = 'phase1-exact-v1'; + $record->workflow_context = $context; + } + $strictEditorial = ($context['editorial_version_binding'] ?? null) === 'phase1-exact-v1'; $state = $this->access->state($record); $before = ['status' => $state, 'publication_status' => $record->status, 'version' => $record->management_version]; $from = match ($action) { @@ -234,7 +239,7 @@ class OpportunityProfileWorkflow } } $baseline = $context['review_baseline'] ?? $context['submission'] ?? []; - $materiality = app(OpportunityReviewMateriality::class)->reconcile($baseline, $snapshot, $context['reviews'] ?? [], $this->contract->gates($record), ! empty($context['force_review'])); + $materiality = app(OpportunityReviewMateriality::class)->reconcile($baseline, $snapshot, $context['reviews'] ?? [], $this->contract->gates($record), ! empty($context['force_review']), $strictEditorial); $context['materiality'] = array_diff_key($materiality, ['reviews' => true]); $context['reviews'] = $materiality['reviews']; app(OpportunityReviewConditions::class)->reconcile($context, $snapshot, $materiality['reopened']); @@ -290,7 +295,7 @@ class OpportunityProfileWorkflow foreach ($this->contract->gates($record) as $gate) { $review = $context['reviews'][$gate] ?? []; abort_unless(($review['decision'] ?? null) === 'approved' && (($review['hash'] ?? '') === $snapshot['hash'] || ($review['accepted_submission_hash'] ?? '') === $snapshot['hash']), 422, 'Gate '.$gate.' pendente, rejeitado ou de outra versão.'); - abort_unless(app(OpportunityReviewMateriality::class)->matches($snapshot, $gate, $review), 409, 'Âmbito da revisão alterado.'); + abort_unless(app(OpportunityReviewMateriality::class)->matches($snapshot, $gate, $review, $strictEditorial), 409, 'Âmbito ou versão da revisão alterado.'); $reviewer = User::whereKey($review['actor_id'])->firstOrFail(); abort_unless($this->access->scoped($reviewer, 'validator', $record->organization_id, $record->opportunity_category) && ! $this->access->conflict($reviewer, $record) && $this->access->operational($reviewer, 'validate', $record->id) @@ -316,6 +321,7 @@ class OpportunityProfileWorkflow foreach ($this->contract->gates($record) as $gate) { $review = $context['approval']['gates'][$gate] ?? []; abort_unless(($review['decision'] ?? null) === 'approved' && (($review['hash'] ?? '') === $snapshot['hash'] || ($review['accepted_submission_hash'] ?? '') === $snapshot['hash']), 422, 'Revisão aprovada desta versão necessária.'); + abort_unless(app(OpportunityReviewMateriality::class)->matches($snapshot, $gate, $review, $strictEditorial), 409, 'Âmbito ou versão jurídica alterados após a aprovação.'); $reviewer = User::whereKey($review['actor_id'])->firstOrFail(); abort_unless($this->access->scoped($reviewer, 'validator', $record->organization_id, $record->opportunity_category) && ! $this->access->conflict($reviewer, $record) && $this->access->operational($reviewer, 'validate', $record->id) @@ -476,6 +482,7 @@ class OpportunityProfileWorkflow $record->management_version++; $record->workflow_context = $context; $record->save(); + app(PortalEditorialWorkflow::class)->notifyOpportunity($actor, $record, $action); $this->access->audit($actor, 'workflow_'.$action, ['record_id' => $record->id, 'before' => $before, 'after' => ['status' => $next, 'publication_status' => $record->status, 'version' => $record->management_version], 'reason' => $data['reason']]); diff --git a/app/Services/OpportunityReviewMateriality.php b/app/Services/OpportunityReviewMateriality.php index 408fa9fd..cd663f88 100644 --- a/app/Services/OpportunityReviewMateriality.php +++ b/app/Services/OpportunityReviewMateriality.php @@ -134,8 +134,12 @@ class OpportunityReviewMateriality /** @param array $snapshot * @param array $review */ - public function matches(array $snapshot, string $gate, array $review): bool + public function matches(array $snapshot, string $gate, array $review, bool $exactLegalVersion = false): bool { + if ($exactLegalVersion && preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1 + && ($review['hash'] ?? null) !== ($snapshot['hash'] ?? null)) { + return false; + } if (! isset($review['scope_hash'])) { return true; // Caller must independently verify the original signed submission hash. } @@ -151,7 +155,7 @@ class OpportunityReviewMateriality * @param array $reviews * @param list $gates * @return array */ - public function reconcile(array $before, array $after, array $reviews, array $gates, bool $force = false): array + public function reconcile(array $before, array $after, array $reviews, array $gates, bool $force = false, bool $exactLegalVersion = false): array { $kept = []; $reopened = []; @@ -161,7 +165,8 @@ class OpportunityReviewMateriality // separately validate old scope signatures under their original rule. $priorHash = $this->hash($before, $gate, $review); $currentHash = $this->hash($after, $gate, $review); - if (! $force && $before !== [] && ($review['decision'] ?? '') === 'approved' && $this->matches($before, $gate, $review) && hash_equals($priorHash, $currentHash)) { + $changedLegalVersion = $exactLegalVersion && preg_match('/legal|registry|authority|confidentiality|license/', $gate) === 1 && ($review['hash'] ?? null) !== ($after['hash'] ?? null); + if (! $force && ! $changedLegalVersion && $before !== [] && ($review['decision'] ?? '') === 'approved' && $this->matches($before, $gate, $review) && hash_equals($priorHash, $currentHash)) { $kept[$gate] = [...$review, 'scope_hash' => $priorHash, 'scope_rule' => self::VERSION, 'accepted_submission_hash' => $after['hash'], 'carried_forward' => ['rule' => self::VERSION, 'from_scope_hash' => $review['scope_hash'] ?? null, 'from_scope_rule' => $review['scope_rule'] ?? 'legacy_unspecified', 'from_submission_hash' => $before['hash'] ?? null, 'at' => now()->toIso8601String()]]; } else { diff --git a/app/Services/PortalEditorialWorkflow.php b/app/Services/PortalEditorialWorkflow.php new file mode 100644 index 00000000..83894f6a --- /dev/null +++ b/app/Services/PortalEditorialWorkflow.php @@ -0,0 +1,444 @@ +,links:list>,files:list>,document_changes:array,contributors:list,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 $resource */ + private function independentPolicy(int $id, array $resource): bool + { + $kind = ($resource['legislation'] ?? false) || ($resource['document_type'] ?? '') === 'legislation' ? 'legislation' : (! empty($resource['editorial_content']) ? 'article' : 'document'); + + return in_array($id, config('editorial_workflow.independent_approval.resources', []), true) || in_array($kind, config('editorial_workflow.independent_approval.kinds', []), true); + } + + public function requiresIndependent(\stdClass $edition): bool + { + $p = $this->payload($edition); + + return $p['policy']['independent_required'] || $this->independentPolicy((int) $edition->resource_id, $p['resource']); + } + + /** @return list> */ + public function notices(User $actor): array + { + if (! $this->available()) { + return []; + } + abort_unless($this->canEnter($actor), 403); + $result = []; + foreach (DB::table('portal_editorial_notifications')->where('user_id', $actor->id)->lazyByIdDesc(100) as $notice) { + if (! $this->visibleNotice($actor, $notice)) { + continue; + } + $edition = $notice->edition_id ? DB::table('portal_editorial_editions')->where('id', $notice->edition_id)->first() : null; + $result[] = ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at, + 'href' => $notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : '/management/editorial/resources/'.$edition->resource_id.'?edition='.$notice->edition_id, 'reference' => $notice->opportunity_id !== null ? 'Revisão '.$notice->opportunity_version : 'Edição '.$edition->revision_no]; + if (count($result) === 50) { + break; + } + } + + return $result; + } + + public function unreadCount(User $actor): int + { + $count = 0; + foreach (DB::table('portal_editorial_notifications')->where('user_id', $actor->id)->whereNull('read_at')->lazyByIdDesc(100) as $notice) { + if ($this->visibleNotice($actor, $notice)) { + $count++; + } + } + + return $count; + } + + /** @return array|null */ + public function dashboard(User $actor): ?array + { + $access = app(DocumentManagementAccess::class); + if (! $this->available() || ! $this->canEnter($actor)) { + return null; + } + + return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor), 'unread' => $this->unreadCount($actor)]; + } + + /** @param array $resource + * @param list> $links + * @param array $documentChanges + * @param list $contributors + * @param array{independent_required:bool} $policy + * @return EditorialPayload + */ + private function snapshot(array $resource, array $links, array $documentChanges = [], array $contributors = [], array $policy = ['independent_required' => false]): array + { + $ids = [(string) $resource['document_id']]; + $body = is_string($resource['editorial_content'] ?? null) ? json_decode($resource['editorial_content'], true, 512, JSON_THROW_ON_ERROR) : ($resource['editorial_content'] ?? null); + if (! empty($body['image_resource_id'])) { + $image = DB::table('portal_resources')->where('id', $body['image_resource_id'])->first(); + abort_unless($image && app(EditorialReading::class)->publicResources()->where('r.id', $image->id)->exists(), 422, 'Imagem pública indisponível.'); + $ids[] = (string) $image->document_id; + } + foreach ($links as $link) { + if ($link['target_type'] === 'document') { + $id = DB::table('portal_resources')->where('id', $link['target_key'])->value('document_id'); + abort_unless($id !== null, 422, 'Anexo documental inexistente.'); + $ids[] = (string) $id; + } + } + $files = array_values(DB::table('documents')->whereIn('id', array_unique($ids))->orderBy('id')->get()->map(function ($document) { + if ($document->status === 'active' && $document->malware_scan_status === 'clean' && $document->deleted_at === null) { + app(VerifiedDocumentBinary::class)->respond($document); + } + + return Arr::only(get_object_vars($document), ['id', 'file_name', 'media_type', 'size_bytes', 'sha256', 'version_no', 'status', 'deleted_at', 'issuer', 'issued_on', 'rights_status', 'data_classification', 'malware_scan_status', 'malware_scanned_sha256']); + })->all()); + abort_unless(count($files) === count(array_unique($ids)), 422, 'Documento não encontrado.'); + + return ['resource' => $resource, 'links' => $links, 'files' => $files, 'document_changes' => $documentChanges, 'contributors' => $contributors, 'policy' => $policy]; + } + + /** @param array $payload */ + private function hash(array $payload): string + { + return hash('sha256', json_encode(Arr::sortRecursive($payload), JSON_THROW_ON_ERROR)); + } + + /** @param array $changes + * @param list>|null $links + */ + public function save(User $actor, int $resource, int $expectedVersion, array $changes, ?array $links = null): \stdClass + { + $this->authorize($actor, $resource, true); + + return DB::transaction(function () use ($actor, $resource, $expectedVersion, $changes, $links) { + $r = DB::table('portal_resources')->where('id', $resource)->lockForUpdate()->first(); + abort_unless($r !== null, 404); + abort_unless((int) $this->workingResource($r)->metadata_version === $expectedVersion, 409, 'A edição mudou. Recupere a versão actual antes de guardar.'); + $previous = $this->latest($resource); + if (! $previous && $r->published) { + $public = $this->snapshot((array) $r, array_values(DB::table('portal_resource_links')->where('resource_id', $resource)->orderBy('id')->get()->map(fn ($l) => get_object_vars($l))->all())); + DB::table('portal_editorial_editions')->insert(['id' => (string) Str::uuid(), 'resource_id' => $resource, 'revision_no' => $expectedVersion, + 'state' => 'published', 'version' => 1, 'payload' => json_encode($public, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($public), + 'author_id' => $r->created_by ?? $actor->id, 'legal_required' => false, 'created_at' => now(), 'updated_at' => now()]); + } + $base = $previous ? $this->payload($previous) : ['resource' => (array) $r, 'links' => array_values(DB::table('portal_resource_links')->where('resource_id', $resource)->orderBy('id')->get()->map(fn ($l) => get_object_vars($l))->all())]; + $fields = array_replace($base['resource'], Arr::except($changes, ['_document', 'id', 'created_by', 'approved_by', 'published', 'metadata_version', 'created_at', 'updated_at', 'independent_review'])); + $fields['metadata_version'] = $expectedVersion + 1; + $fields['independent_review'] = false; + $contributors = array_values(array_unique([...($previous && $previous->state !== 'published' ? ($base['contributors'] ?? [$previous->author_id]) : []), $actor->id])); + $snapshot = $this->snapshot($fields, $links ?? $base['links'], Arr::only($changes['_document'] ?? $base['document_changes'] ?? [], ['issuer', 'issued_on', 'rights_status', 'data_classification']), $contributors, ['independent_required' => $this->independentPolicy($resource, $fields)]); + foreach ($snapshot['files'] as $file) { + abort_unless($this->canReadFile($actor, $file['id']), 403); + } + $id = (string) Str::uuid(); + DB::table('portal_editorial_editions')->insert(['id' => $id, 'resource_id' => $resource, 'revision_no' => $expectedVersion + 1, + 'state' => 'draft', 'version' => 1, 'payload' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($snapshot), + 'author_id' => $actor->id, 'legal_required' => (bool) ($r->legislation || ($previous->legal_required ?? false) || ($fields['legislation'] ?? false)) || ($fields['document_type'] ?? '') === 'legislation', 'created_at' => now(), 'updated_at' => now()]); + $edition = DB::table('portal_editorial_editions')->where('id', $id)->first(); + $this->audit($actor, $edition, 'draft_saved', 'Edição privada guardada; versão pública preservada.'); + + return $edition; + }); + } + + /** @return array */ + private function decision(User $actor, \stdClass $edition, string $reason): array + { + return ['actor_id' => $actor->id, 'edition_id' => $edition->id, 'hash' => $edition->content_hash, 'reason' => $reason, 'at' => now()->toISOString()]; + } + + /** @param array $input */ + public function transition(User $actor, int $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 $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]); + } +} diff --git a/config/editorial_workflow.php b/config/editorial_workflow.php new file mode 100644 index 00000000..dc1e754f --- /dev/null +++ b/config/editorial_workflow.php @@ -0,0 +1,10 @@ + env('PORTAL_EDITORIAL_WORKFLOW_ENABLED', false), + // Legal designation is explicit; management authority alone is not a legal designation. + 'legal_reviewers' => [], + // Required separation is a policy, distinct from the legacy fact "independent_review". + 'independent_approval' => ['resources' => [], 'kinds' => []], +]; diff --git a/database/migrations/2026_11_13_220000_create_portal_editorial_editions.php b/database/migrations/2026_11_13_220000_create_portal_editorial_editions.php new file mode 100644 index 00000000..99fb2042 --- /dev/null +++ b/database/migrations/2026_11_13_220000_create_portal_editorial_editions.php @@ -0,0 +1,54 @@ +uuid('id')->primary(); + $t->foreignId('resource_id')->constrained('portal_resources')->restrictOnDelete(); + $t->unsignedInteger('revision_no'); + $t->unsignedInteger('version')->default(1); + $t->string('state', 24)->default('draft'); + $t->jsonb('payload'); + $t->char('content_hash', 64); + $t->foreignId('author_id')->constrained('users')->restrictOnDelete(); + $t->foreignId('assigned_to')->nullable()->constrained('users')->restrictOnDelete(); + $t->boolean('legal_required')->default(false); + $t->jsonb('approval')->nullable(); + $t->jsonb('legal_validation')->nullable(); + $t->timestampTz('published_at')->nullable(); + $t->timestampsTz(); + $t->unique(['resource_id', 'revision_no']); + $t->index(['state', 'assigned_to']); + }); + Schema::create('portal_editorial_notifications', function (Blueprint $t): void { + $t->id(); + $t->uuid('edition_id'); + $t->foreign('edition_id')->references('id')->on('portal_editorial_editions')->restrictOnDelete(); + $t->foreignId('user_id')->constrained('users')->restrictOnDelete(); + $t->string('action', 40); + $t->timestampTz('read_at')->nullable(); + $t->timestampTz('created_at'); + $t->index(['user_id', 'read_at']); + }); + } + + public function down(): void + { + if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_editions')->exists() || DB::table('portal_editorial_notifications')->exists()) { + throw new RuntimeException('Preserve editions, decisions and history.'); + } + Schema::dropIfExists('portal_editorial_notifications'); + Schema::dropIfExists('portal_editorial_editions'); + } +}; diff --git a/database/migrations/2026_11_13_223000_extend_editorial_notifications_to_opportunities.php b/database/migrations/2026_11_13_223000_extend_editorial_notifications_to_opportunities.php new file mode 100644 index 00000000..08e4f4cd --- /dev/null +++ b/database/migrations/2026_11_13_223000_extend_editorial_notifications_to_opportunities.php @@ -0,0 +1,39 @@ +uuid('edition_id')->nullable()->change(); + $t->foreignId('opportunity_id')->nullable()->constrained('opportunities')->restrictOnDelete(); + $t->unsignedInteger('opportunity_version')->nullable(); + $t->char('content_hash', 64)->nullable(); + $t->unique(['user_id', 'opportunity_id', 'opportunity_version', 'action'], 'portal_editorial_op_notice_uq'); + }); + DB::statement('ALTER TABLE portal_editorial_notifications ADD CONSTRAINT portal_editorial_notice_subject CHECK ((edition_id IS NOT NULL AND opportunity_id IS NULL) OR (edition_id IS NULL AND opportunity_id IS NOT NULL AND opportunity_version IS NOT NULL))'); + } + + public function down(): void + { + if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_notifications')->whereNotNull('opportunity_id')->exists()) { + throw new RuntimeException('Preserve opportunity notification history.'); + } + DB::statement('ALTER TABLE portal_editorial_notifications DROP CONSTRAINT portal_editorial_notice_subject'); + Schema::table('portal_editorial_notifications', function (Blueprint $t): void { + $t->dropUnique('portal_editorial_op_notice_uq'); + $t->dropForeign(['opportunity_id']); + $t->dropColumn(['opportunity_id', 'opportunity_version', 'content_hash']); + $t->uuid('edition_id')->nullable(false)->change(); + }); + } +}; diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index b5ec8938..0800c419 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -1,3 +1,19 @@ +## 13/11/2026 — Dashboard Colaborativo, Fase 1 implementada em desenvolvimento/QA + +**Âmbito:** artigos, legislação, documentos e integração do circuito existente de oportunidades. A versão pública de um artigo permanece integralmente inalterada ao guardar ou rever uma nova edição privada, incluindo metadados, identificador e contador públicos. Não houve intervenção na homologação ou produção nesta passagem. + +**Utilização em QA:** depois de instalar as duas migrações aditivas em base isolada e activar `PORTAL_EDITORIAL_WORKFLOW_ENABLED`, entrar pelo Painel/Gestão de conteúdos → Circuito editorial (`/management/editorial`). Alterar no editor existente, guardar a edição, conferir conteúdo/anexos e submeter com fundamento e responsável. O revisor inicia a revisão, valida juridicamente quando exigido e aprova. Publicar é uma segunda operação explícita. Devolução, rejeição e arquivo exigem fundamento. Arquivar um rascunho preserva a publicação anterior; arquivar a edição pública actual retira-a explicitamente. A fila permite filtrar tipo/estado e consultar versões, responsáveis e histórico. As notificações internas ligam à edição concreta ou à oportunidade no circuito nativo e podem ser marcadas como lidas. + +**Autorizações e versões:** verificações no servidor para HTML, JSON, prévias e anexos; MFA existente nas escritas; escopos e permissões existentes, incluindo revogação de acesso. Aprovação e validação jurídica vinculadas à edição, conteúdo e versões/hashes dos anexos. Alterações ou bytes diferentes impedem reaproveitar a decisão. Conflitos de edição devolvem 409. Auto-aprovação bloqueada quando a política independente a exige, abrangendo todos os colaboradores da edição. A configuração `editorial_workflow.independent_approval` distingue a obrigação de independência do campo legado que apenas regista a revisão efectuada. A designação jurídica em `legal_reviewers` é explícita; não foram criadas contas, perfis ou concessões. A fonte oficial já aceite não é novamente auditada por este circuito. + +**Oportunidades:** reutilizam filas, atribuições, revisões, publicador independente, prévias e histórico existentes. Novas submissões com a Fase 1 activa vinculam a validação jurídica à versão exacta e à versão dos anexos. Submissões já aprovadas mantêm o contrato/hash anterior. Notificações próprias e resumo de pendências entram no painel sem conceder acesso a outra organização ou conteúdo. Não existe um segundo motor de oportunidades. + +**Verificação:** testes de integração do novo circuito, 11 aprovados/223 asserções; regressão e aceitação nativa de oportunidades, 6 aprovados/116 asserções, mais o caso reforçado de notificações isolado, 1 aprovado/20 asserções; unidade jurídica, 1 aprovado/6 asserções; frontend, 12 aprovados. PHPStan focado e tipos/compilação aprovados. A tentativa inicial nativa sem activos de compilação e com armazenamento de fixtures partilhado foi descartada; as provas aceites usam activos e armazenamento QA separados. Provas locais: `outputs/editorial-phase1-20261113/` (`integration-final.log`, `opportunity-acceptance.log`, `opportunity-notices.log`, `legal-unit.log`, `frontend-final.log`, `analysis-final.log`, `types-final.log`, `build-delivery.log`). + +**Estrutura e preservação:** base própria `editorial_phase1_20261113_test`, PostgreSQL de QA 55441. Primeira migração criou duas tabelas vazias; segunda acrescentou o vínculo de notificações às oportunidades. Recibos `qa-installation.json` e `qa-notification-installation.json`: contagens das 175/177 tabelas preexistentes preservadas em cada etapa, excepto incremento documentado do livro de migrações. Sem preenchimento retroactivo, publicação automática, alteração de contas/permissões, nem escrita em bases operacionais. Reversão das migrações recusa apagar histórico existente; desactivar o novo módulo preserva dados e exige planear qualquer retoma de edição legada. + +**Saldo:** implementado e demonstrado por testes em QA; conferência visual no navegador pendente. Activação operacional, designações jurídicas/independência efectivas e instalação fora do QA não executadas, dependem de decisão concreta. As migrações desta fase recusam destinos operacionais. Fase 2 (projectos âncora, clusters, estatística, demografia e conteúdos territoriais) continua delimitada para trabalho posterior; não é encerrada por esta entrega. Preservadas as entregas anteriores e o trabalho paralelo na pasta original. + ## 13/11/2026 — pesquisa rápida: intenções e categorias, implementada em QA A pesquisa reconhece contagens do catálogo publicado (por exemplo, «quantos corredores existem»), sem misturar projectos ou oportunidades apenas por mencionarem corredores. Zero registos e indisponibilidade de uma fonte têm apresentação distinta. «Como licenciar uma empresa» conduz ao roteiro existente e à preparação de S08 no dossier, sem inventar requisitos oficiais. diff --git a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv index 445770f4..20addee9 100644 --- a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv +++ b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv @@ -11128,3 +11128,4 @@ AUD-SIM-L1-P133,Saída de regimes suspensivos e operações de viagens,Refinamen INE-DEMO-01,"Integração nacional de dados demográficos INE na viabilidade existente, importador em duas etapas, séries/versionamento, comparação territorial e adenda nacional.",Pedido colado pelo proprietário nesta sessão; execução expressamente confirmada; Macate/Vanduzi autorizados apenas em QA em 13/11/2026.,Território; viabilidade; percurso; SWOT; simulador,/localizacoes/demografia; /api/v1/investment/demography; /management/demography,DemographicImport; DemographicEvidence; leitor XLSX; cadastro/mapa/comparação/simulador e fontes SWOT existentes reutilizados.,"Homologação:4988ca89 instalado,erro500 de processos corrigido;preparaçõesCLI12/920456 privadas,por confirmaçãoMFA. Local:12lotes/920456 instalados e verificados.","154706 observações reais INE; três originais com scan real/hash; repetição sem duplicação; 72 reconciliações entre exemplares; comparação GONDOLA/GURO. Homologação: backup/restauro190 tabelas/369 ficheiros;11 tabelas de negócio preservadas;168 correspondências+15 distritos autorizados,sem pendências;HTTPS200 e revisão b20068d0 confirmada. Correcção df543a44:3testes/17asserções;HTTPS confirma revisão;ambos os titulares designados passam a autorização de leitura;nenhuma permissão alterada;hashes de código/configuração conferidos. Correcção500:2testes/20asserções sem proc_open;14testes/52asserções consumidores;8preparador;PHPStan aprovado. PHP-FPM real confirmou processos bloqueados,cacheCLI e antimalware disponíveis,hashes correctos,sem actor/sessão ou escrita de negócio;HTTPS confirma4988ca89.",Código instalado e verificado no PHP-FPM real;incorporação remota dos dados pendente da confirmação autenticada.,"Confirmar coortes initial/remaining/zambezia na sessão com MFA e conferir recibos,12 lotes/920456 observações e consulta territorial. Conferência visual administrativa pendente.",PostgreSQL/PostGIS QA; ClamAV; Python stdlib; autenticação e MFA existentes; nenhum novo aprovador.,"Três ficheiros associados aos territórios, valores exactos, séries 2017–2050, consulta sectorial/temporal, duas localizações, fontes/versões, inconsistências e acessos demonstrados em QA. Homologação manual restante registada no relatório.",Alta,,,,,"Consulta pública desktop/mobile conferida; visual administrativa pendente, testes HTTP aprovados.",11 testes de integração aprovados.,Três lotes QA; 154706 observações; idempotência e unicidade verificadas.,Consulta por território/ano/exemplar; desactivação/reaprovação preservam histórico.,"População/idade/residência, vitais, fontes e limitações; sem extrapolação, procura automática ou probabilidade de sucesso.",,,,,"Sem pendência de importação dos ficheiros provinciais fornecidos. Conferência visual administrativa,ressalvas/unidades ausentes e indicadores sem valores mantidos;outros ambientes não instalados por esta operação.","PortalProfileInstallation/MFA existentes; DemographicInstallation; DemographicImport; InvestmentGeography; simulador, mapa/comparação e fontes SWOT existentes.",,Proprietário executou a disponibilização autorizada de Zambezia.xlsx e cinco distritos;recibo autenticado e resultado confirmados sem repetição.,,,"outputs/demography-20261113/zambezia/installation-status.json e verification.json:recibo autenticado09:33:38UTC,hash/lote/count126684,total12lotes/920456,preservação11lotes e oito tabelas de negócio,série provincial e cinco distritos públicos. operational-status.json actualizado;ensaios e backup/restauro anteriores preservados.",demografia-ine,13/11/2026 — instalação autenticada concluída; conferência técnica de leitura sem repetição da operação,,,,,,,,,,,,,, REL-DOSSIER-20261113,"Agrupar no dossier correspondente os relatórios produzidos pelas etapas, com versão, oportunidade e recuperação.",Pedido expresso do proprietário em 13/11/2026: «tem de ser resolvido».,Percurso do investidor; dossier; relatórios,/investimentos/{id}#relatorios; /investimentos/{id}/relatorios/fiscais/{reportId},"Índice e relatório consolidado no percurso existente; reutiliza os geradores Word/HTML, cálculos e cópias preservadas.",Homologação teste-investidor.tempo.co.mz instalada em 07182c7c; /investimentos/{id}#relatorios.,"21 testes frontend; regressão servidor 4/168 asserções e caso final 45 asserções; tipos,lint,PHPStan e build aprovados. HTTPS e preservação conferidos na revisão instalada.",Consolidado instalado em 07182c7c; resumos por etapa conferidos em QA; delta sem instalação,Novo dossier sintético: 19 consultas e 38 descargas reais Word/HTML conferidas; recuperação após actualização. Falta instalar o delta por etapa; saídas específicas ainda não produzidas mantêm o saldo próprio.,,"Mesmo dossier/oportunidade → índice por etapa → relatório Word/HTML → acesso a versões APIEX, fiscais e documentos → recuperação sem misturar oportunidades ou expor finanças a gestores.",Alta,,,,,Índice responsivo e exportação consolidada,"Descarga privada da cópia fiscal preservada, autorizada por titular e dossier","Reutiliza os registos existentes; associação fiscal nova conserva âmbito, sem reescrever fontes anteriores.",Aprovada em prova sintética; sem depender de acesso à simulação anónima original.,Versão/data/estado/origem; pendências e âmbito por confirmar explícitos.,,,,,,,,"Acrescentar índice e consolidar saídas existentes; sem módulo paralelo, publicação ou envio automático.",Consulta e descargas por etapa concluídas em QA; instalação do novo delta pendente,,outputs/dossier-reports-20261113/verification.json; outputs/dossier-reports-20261113/homologation-installed.json; outputs/dossier-reports-20261113/stage-authenticated-download-verification.json,,,,,,,,,,,,,,,, ACT-20261113-PESQUISA,Pesquisa rápida: contar corredores sem misturar referências; produtos físicos e financeiros separados por categoria e localização; como licenciar empresa.,Pedidos Tempo na conversa de 13/11/2026,Pesquisa rápida,/pesquisa; /,"SearchTerms::requestIntent, GlobalSearchController, FederatedSearch, portal/search, portal-hero; reutiliza catálogos publicados existentes.",Implementado em QA; nova candidata ainda não instalada.,"outputs/quick-search-20261113/: integration.log, regression.log, unit.log, frontend.log, types.log, analysis.log, build.log",QA implementado e testes focados aprovados; instalação e visual pendentes,Instalar após autorização concreta; conferir apresentação no navegador.,,Contagem tipada e zero distinto de falha; produtos separados com localização/cobertura e contexto nos links; licenciamento no roteiro/dossier existentes; só publicados; erros de fontes explícitos; paginação preservada.,Alta,,,,,,,,,,,,,,"Conferência visual e instalação desta candidata; pesquisa territorial é textual, sem inferir elegibilidade.",,,,Implementação QA concluída neste recorte; entrega preparada.,,,,,,,,,,,,,,,,,, +ACT-20261113-EDITORIAL-F1,"Dashboard Colaborativo Fase 1: versões e autorizações; artigos, legislação, oportunidades e documentos; filas e notificações",Directiva Tempo 13/11/2026 nesta sessão; plano aprovado e autorização de implementação apenas em desenvolvimento/QA,Gestão editorial / conteúdos,/management/editorial; /management/editorial/resources/{id}; edições/anexos/notificações; editores e circuito nativo de oportunidades,Edições privadas imutáveis no circuito; versão pública preservada; aprovação/publicação separadas; decisões ligadas a conteúdo/anexos; permissões e auditoria reutilizadas; notificações com acesso revogável,Desactivado por defeito fora dos testes; estrutura instalada apenas em editorial_phase1_20261113_test:55441,outputs/editorial-phase1-20261113: integração 11/223; oportunidades 6/116 + caso reforçado 1/20; unidade 1/6; frontend 12; PHPStan/tipos/build aprovados,Implementado em QA; conferência visual e instalação operacional pendentes,Conferência visual; configuração operacional de designações e independência; instalação/activação fora do QA não executadas,Decisão concreta para instalação operacional e designações; não bloqueia as provas sintéticas QA,Editar preserva toda a publicação; JSON/prévias/anexos autorizados; conteúdo/anexos novos exigem nova decisão; aprovação distinta da publicação; filas e notificações isoladas; conflitos 409; histórico recuperável,Alta,,,,,"Fila, filtros, edição, histórico e notificações em QA","JSON nas mesmas rotas privadas, com as mesmas autorizações","Edições, decisões, notificações e auditoria; dados sintéticos revertidos pelos testes",Versões e publicação anterior recuperadas; isolamento/revogação demonstrados,Versão pública mantida durante edição e revisão; substituição só por publicação explícita,,,,"Implementação focada em lacunas, sem segundo motor de oportunidades ou alteração de permissões",Conferência visual; instalação operacional separada; Fase 2 não implementada,"DocumentManagementAccess, PortalProfileAccess, EditorialReading, VerifiedDocumentBinary, auditoria e circuito nativo de oportunidades",,Implementar em desenvolvimento/QA; não intervir em produção,Implementação aditiva e testes focados,,Recibos QA e testes em outputs/editorial-phase1-20261113; manual unificado actualizado,Dashboard Colaborativo Fase 1,13/11/2026,,,,,,,,"Reutilização dos testes nativos de oportunidade, permissão, prévias e integridade",Versão pública preservada; circuito editorial e decisões por versão/anexos; escopos de notificações e conteúdos,Sem conferência visual; sem operação em homologação/produção; tentativa inicial descartada explicitamente,,Percursos editoriais e autorizações,Artigos → legislação/documentos → oportunidades reutilizadas → fila/painel/notificações,QA demonstrado; instalação não executada; Fase 2 pendente diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 18f77122..b33733e6 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -36,10 +36,12 @@ export default function Dashboard({ investorPath, opportunityManagement, workQueue, + editorialQueue, }: { summary: Summary | null; opportunityManagement?: OpportunityManagementAccess | null; workQueue?: DashboardQueue | null; + editorialQueue?: { pending: number; unread: number } | null; investorPath?: { contexts: InvestmentContext[]; current: { @@ -59,6 +61,21 @@ export default function Dashboard({ + {editorialQueue && ( +
+

Circuito editorial

+

+ {editorialQueue.pending} conteúdos pendentes ·{' '} + {editorialQueue.unread} notificações por ler +

+ + Abrir fila editorial e histórico + +
+ )} {summary ? ( <>
+ {access.editorial && ( +
+

+ Circuito editorial +

+

+ Preparar, rever, aprovar e publicar versões de + artigos, legislação e documentos. +

+ + Abrir fila editorial + +
+ )} {access.procurement && (

diff --git a/resources/js/pages/management/editorial-edition.tsx b/resources/js/pages/management/editorial-edition.tsx new file mode 100644 index 00000000..784b463d --- /dev/null +++ b/resources/js/pages/management/editorial-edition.tsx @@ -0,0 +1,331 @@ +import { Link, useForm } from '@inertiajs/react'; +import { Management, Field, button, control } from './shared'; +import { states } from './editorial-workflow'; + +type Edition = { + id: string; + revision_no: number; + version: number; + state: string; + content_hash: string; + legal_required: boolean; + fresh: boolean; + payload: { + resource: { + title: string; + description?: string; + editorial_content?: string | null; + }; + files: { + id: string; + file_name: string; + version_no: number; + sha256: string; + }[]; + }; + approval: string | null; + legal_validation: string | null; +}; +export default function EditorialEdition({ + resource, + edition, + manager, + legalReviewer, + history, + editions, + reviewers, + current, + canApprove, + independentRequired, +}: { + resource: { + id: number; + title: string; + published: boolean; + public_edition_no?: number | null; + }; + edition: Edition | null; + manager: boolean; + legalReviewer: boolean; + history: { + id: number; + action: string; + actor_user_id: number; + after_data: string; + occurred_at: string; + }[]; + editions: { id: string; revision_no: number; state: string }[]; + reviewers: { id: number; name: string }[]; + current: boolean; + canApprove: boolean; + independentRequired: boolean; +}) { + const form = useForm({ + version: edition?.version ?? 1, + action: '', + reason: '', + assigned_to: '', + }); + const available: Record = { + draft: ['submit', 'archive'], + returned: ['submit', 'archive'], + submitted: ['start_review', 'archive'], + in_review: ['return', 'reject', 'legal_validate', 'approve', 'archive'], + approved: ['publish', 'archive'], + rejected: ['archive'], + published: ['archive'], + archived: [], + }; + const labels: Record = { + submit: 'Submeter', + start_review: 'Iniciar revisão', + return: 'Devolver', + reject: 'Rejeitar', + legal_validate: 'Validar juridicamente', + approve: 'Aprovar', + publish: 'Publicar', + archive: 'Arquivar', + }; + return ( + +
+ + Fila e notificações + + + Alterar artigo + + + Alterar metadados/documento + +
+

+ {resource.published + ? 'A versão pública continua disponível até uma publicação ou retirada explícita.' + : 'Ainda não existe uma versão pública disponível.'} +

+ {resource.public_edition_no && ( +

Versão pública: edição {resource.public_edition_no}.

+ )} + {edition ? ( + <> +

+ Edição {edition.revision_no} ·{' '} + {states[edition.state] ?? edition.state} +

+ {!edition.fresh && ( +

+ Os anexos mudaram ou deixaram de estar disponíveis. + Guarde uma nova edição e volte a conferir antes da + aprovação. +

+ )} +

+ Identificador da edição: {edition.id} +
+ Hash do conteúdo e anexos: {edition.content_hash} +

+

Conferir conteúdo

+

{edition.payload.resource.title}

+

+ {edition.payload.resource.description} +

+ {edition.payload.resource.editorial_content && ( +

+ { + ( + JSON.parse( + edition.payload.resource + .editorial_content, + ) as { body?: string } + ).body + } +

+ )} + +

+ {edition.legal_required + ? 'Validação jurídica obrigatória para esta edição.' + : 'Sem validação jurídica obrigatória definida para este tipo de conteúdo.'} +

+

+ {edition.state === 'published' && !edition.approval + ? 'Publicação anterior conservada, sem fabricar novas aprovações.' + : `Aprovação: ${edition.approval ? 'Registada nesta edição' : 'Pendente'}`}{' '} + · Validação jurídica:{' '} + {edition.legal_validation + ? 'Registada nesta edição' + : 'Não registada'} +

+ {independentRequired && current && ( +

+ Esta edição exige um responsável independente para a + aprovação. +

+ )} + {current ? ( +
{ + e.preventDefault(); + form.transform((data) => ({ + ...data, + version: edition.version, + assigned_to: data.assigned_to || null, + })); + form.post( + `/management/editorial/resources/${resource.id}/editions/${edition.id}`, + { + onSuccess: () => + form.reset('action', 'reason'), + }, + ); + }} + className="space-y-3" + > + + + + {form.data.action === 'submit' && ( + + + + )} + +