feat(editorial): preserve public editions and add QA review workflow
This commit is contained in:
@@ -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)]),
|
||||
|
||||
@@ -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.');
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\ContentManagement;
|
||||
use App\Services\DocumentManagementAccess;
|
||||
use App\Services\PortalEditorialWorkflow;
|
||||
use App\Services\VerifiedDocumentBinary;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class PortalEditorialWorkflowController extends Controller
|
||||
{
|
||||
private const KIND = "CASE WHEN e.payload->'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<string,mixed> $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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']]);
|
||||
|
||||
|
||||
@@ -134,8 +134,12 @@ class OpportunityReviewMateriality
|
||||
|
||||
/** @param array<string,mixed> $snapshot
|
||||
* @param array<string,mixed> $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<string,mixed> $reviews
|
||||
* @param list<string> $gates
|
||||
* @return array<string,mixed> */
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Development/QA opt-in. Installation and activation in an operational target are separate decisions.
|
||||
'enabled' => 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' => []],
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
|
||||
throw new RuntimeException('This first-stage migration is authorised only in isolated QA.');
|
||||
}
|
||||
Schema::create('portal_editorial_editions', function (Blueprint $t): void {
|
||||
$t->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');
|
||||
}
|
||||
};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
|
||||
throw new RuntimeException('Only isolated QA is authorised.');
|
||||
}
|
||||
Schema::table('portal_editorial_notifications', function (Blueprint $t): void {
|
||||
$t->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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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({
|
||||
<ProfileNavigation />
|
||||
<ContentManagementEntry />
|
||||
<DashboardWorkQueue queue={workQueue} />
|
||||
{editorialQueue && (
|
||||
<section
|
||||
className="rounded-lg border p-4"
|
||||
aria-label="Trabalho editorial"
|
||||
>
|
||||
<h2 className="font-semibold">Circuito editorial</h2>
|
||||
<p>
|
||||
{editorialQueue.pending} conteúdos pendentes ·{' '}
|
||||
{editorialQueue.unread} notificações por ler
|
||||
</p>
|
||||
<Link className="underline" href="/management/editorial">
|
||||
Abrir fila editorial e histórico
|
||||
</Link>
|
||||
</section>
|
||||
)}
|
||||
{summary ? (
|
||||
<>
|
||||
<Link
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Frame, card, button } from '@/pages/investment/shared';
|
||||
import { states } from './shared';
|
||||
|
||||
type Access = {
|
||||
editorial?: { index: string };
|
||||
procurement?: { index: string; create: string };
|
||||
opportunities?: {
|
||||
index: string;
|
||||
@@ -26,6 +27,20 @@ export default function ContentHub({
|
||||
acção respeita a sua autorização actual.
|
||||
</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{access.editorial && (
|
||||
<section className={card}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Circuito editorial
|
||||
</h2>
|
||||
<p>
|
||||
Preparar, rever, aprovar e publicar versões de
|
||||
artigos, legislação e documentos.
|
||||
</p>
|
||||
<Link className={button} href={access.editorial.index}>
|
||||
Abrir fila editorial
|
||||
</Link>
|
||||
</section>
|
||||
)}
|
||||
{access.procurement && (
|
||||
<section className={card}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
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<string, string> = {
|
||||
submit: 'Submeter',
|
||||
start_review: 'Iniciar revisão',
|
||||
return: 'Devolver',
|
||||
reject: 'Rejeitar',
|
||||
legal_validate: 'Validar juridicamente',
|
||||
approve: 'Aprovar',
|
||||
publish: 'Publicar',
|
||||
archive: 'Arquivar',
|
||||
};
|
||||
return (
|
||||
<Management title={`Edição — ${resource.title}`}>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link className="underline" href="/management/editorial">
|
||||
Fila e notificações
|
||||
</Link>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/management/portal-services/resources/${resource.id}/article`}
|
||||
>
|
||||
Alterar artigo
|
||||
</Link>
|
||||
<Link className="underline" href="/management/portal-services">
|
||||
Alterar metadados/documento
|
||||
</Link>
|
||||
</div>
|
||||
<p>
|
||||
{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.'}
|
||||
</p>
|
||||
{resource.public_edition_no && (
|
||||
<p>Versão pública: edição {resource.public_edition_no}.</p>
|
||||
)}
|
||||
{edition ? (
|
||||
<>
|
||||
<h2 className="font-semibold">
|
||||
Edição {edition.revision_no} ·{' '}
|
||||
{states[edition.state] ?? edition.state}
|
||||
</h2>
|
||||
{!edition.fresh && (
|
||||
<p role="alert">
|
||||
Os anexos mudaram ou deixaram de estar disponíveis.
|
||||
Guarde uma nova edição e volte a conferir antes da
|
||||
aprovação.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm break-all">
|
||||
Identificador da edição: {edition.id}
|
||||
<br />
|
||||
Hash do conteúdo e anexos: {edition.content_hash}
|
||||
</p>
|
||||
<h3 className="font-semibold">Conferir conteúdo</h3>
|
||||
<p>{edition.payload.resource.title}</p>
|
||||
<p className="whitespace-pre-wrap">
|
||||
{edition.payload.resource.description}
|
||||
</p>
|
||||
{edition.payload.resource.editorial_content && (
|
||||
<p className="whitespace-pre-wrap">
|
||||
{
|
||||
(
|
||||
JSON.parse(
|
||||
edition.payload.resource
|
||||
.editorial_content,
|
||||
) as { body?: string }
|
||||
).body
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
<ul>
|
||||
{edition.payload.files.map((file) => (
|
||||
<li key={file.id}>
|
||||
<a
|
||||
className="underline"
|
||||
href={`/management/editorial/resources/${resource.id}/editions/${edition.id}/files/${file.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{file.file_name} · versão {file.version_no}
|
||||
</a>
|
||||
<span className="block text-xs break-all">
|
||||
SHA256: {file.sha256}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>
|
||||
{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.'}
|
||||
</p>
|
||||
<p>
|
||||
{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'}
|
||||
</p>
|
||||
{independentRequired && current && (
|
||||
<p>
|
||||
Esta edição exige um responsável independente para a
|
||||
aprovação.
|
||||
</p>
|
||||
)}
|
||||
{current ? (
|
||||
<form
|
||||
key={edition.version}
|
||||
onSubmit={(e) => {
|
||||
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"
|
||||
>
|
||||
<Field label="Operação">
|
||||
<select
|
||||
required
|
||||
className={control}
|
||||
value={form.data.action}
|
||||
onChange={(e) =>
|
||||
form.setData('action', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{(available[edition.state] ?? [])
|
||||
.filter(
|
||||
(action) =>
|
||||
(manager ||
|
||||
[
|
||||
'submit',
|
||||
'archive',
|
||||
].includes(action)) &&
|
||||
(action !== 'legal_validate' ||
|
||||
(legalReviewer &&
|
||||
canApprove)) &&
|
||||
(action !== 'approve' ||
|
||||
canApprove) &&
|
||||
(edition.fresh ||
|
||||
action === 'archive'),
|
||||
)
|
||||
.map((action) => (
|
||||
<option key={action} value={action}>
|
||||
{labels[action]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{form.data.action === 'submit' && (
|
||||
<Field label="Responsável pela revisão">
|
||||
<select
|
||||
className={control}
|
||||
value={form.data.assigned_to}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'assigned_to',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Por atribuir</option>
|
||||
{reviewers.map((reviewer) => (
|
||||
<option
|
||||
key={reviewer.id}
|
||||
value={reviewer.id}
|
||||
>
|
||||
{reviewer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Fundamento">
|
||||
<textarea
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={5000}
|
||||
className={control}
|
||||
value={form.data.reason}
|
||||
onChange={(e) =>
|
||||
form.setData('reason', e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{Object.entries(form.errors).map(([key, error]) => (
|
||||
<p key={key} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
<button
|
||||
className={button}
|
||||
disabled={form.processing || !form.data.action}
|
||||
>
|
||||
Registar operação
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p>
|
||||
Versão histórica para consulta.{' '}
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/management/editorial/resources/${resource.id}`}
|
||||
>
|
||||
Voltar à edição actual
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
Prepare uma edição no editor existente para iniciar a
|
||||
revisão.
|
||||
</p>
|
||||
)}
|
||||
<h2 className="font-semibold">Versões conservadas</h2>
|
||||
<ul>
|
||||
{editions.map((e) => (
|
||||
<li key={e.id}>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/management/editorial/resources/${resource.id}?edition=${e.id}`}
|
||||
>
|
||||
Edição {e.revision_no}: {states[e.state] ?? e.state}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h2 className="font-semibold">Histórico de operações</h2>
|
||||
<ul>
|
||||
{history.map((event) => (
|
||||
<li key={event.id}>
|
||||
{event.occurred_at} ·{' '}
|
||||
{labels[event.action.replace('editorial_', '')] ??
|
||||
'Edição guardada'}{' '}
|
||||
· Utilizador {event.actor_user_id}
|
||||
<p>{JSON.parse(event.after_data)?.reason}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Management>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { Field, Management, button, control } from './shared';
|
||||
|
||||
export const states: Record<string, string> = {
|
||||
draft: 'Rascunho',
|
||||
submitted: 'Submetido',
|
||||
in_review: 'Em revisão',
|
||||
approved: 'Aprovado',
|
||||
published: 'Publicado',
|
||||
returned: 'Devolvido',
|
||||
rejected: 'Rejeitado',
|
||||
archived: 'Arquivado',
|
||||
};
|
||||
type Item = {
|
||||
id: number;
|
||||
title: string;
|
||||
revision_no: number;
|
||||
state: string;
|
||||
responsible: string | null;
|
||||
published: boolean;
|
||||
legal_required: boolean;
|
||||
};
|
||||
const eventTitles: Record<string, string> = {
|
||||
submit: 'Submissão',
|
||||
start_review: 'Início da revisão',
|
||||
return: 'Devolução',
|
||||
reject: 'Rejeição',
|
||||
legal_validate: 'Validação jurídica',
|
||||
approve: 'Aprovação',
|
||||
publish: 'Publicação',
|
||||
archive: 'Arquivo',
|
||||
};
|
||||
export default function EditorialWorkflow({
|
||||
items,
|
||||
notices,
|
||||
filters = {},
|
||||
resourceAccess,
|
||||
opportunitiesUrl,
|
||||
opportunityPending,
|
||||
}: {
|
||||
resourceAccess: boolean;
|
||||
opportunitiesUrl: string | null;
|
||||
opportunityPending: number;
|
||||
filters?: { state?: string; kind?: string };
|
||||
items: { data: Item[]; current_page: number; last_page: number };
|
||||
notices: {
|
||||
id: number;
|
||||
action: string;
|
||||
read_at: string | null;
|
||||
href: string;
|
||||
reference: string;
|
||||
}[];
|
||||
}) {
|
||||
const pageUrl = (page: number) =>
|
||||
`/management/editorial?${new URLSearchParams({ ...filters, page: String(page) })}`;
|
||||
return (
|
||||
<Management title="Circuito editorial">
|
||||
<p>
|
||||
Artigos, legislação e documentos: edições privadas e versão
|
||||
pública separadas. As oportunidades mantêm o seu circuito
|
||||
próprio.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link className="underline" href="/dashboard">
|
||||
Painel geral
|
||||
</Link>
|
||||
{resourceAccess && (
|
||||
<Link
|
||||
className="underline"
|
||||
href="/management/portal-services"
|
||||
>
|
||||
Biblioteca e editores
|
||||
</Link>
|
||||
)}
|
||||
{opportunitiesUrl && (
|
||||
<Link className="underline" href={opportunitiesUrl}>
|
||||
Oportunidades
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{opportunitiesUrl && (
|
||||
<section
|
||||
aria-label="Circuito de oportunidades"
|
||||
className="my-4 rounded border p-4"
|
||||
>
|
||||
<h2 className="font-semibold">Oportunidades</h2>
|
||||
<p>
|
||||
{opportunityPending} conteúdos pendentes no seu âmbito
|
||||
de acesso.
|
||||
</p>
|
||||
<Link className="underline" href={opportunitiesUrl}>
|
||||
Continuar na fila de oportunidades existente
|
||||
</Link>
|
||||
</section>
|
||||
)}
|
||||
{resourceAccess && (
|
||||
<>
|
||||
<form
|
||||
action="/management/editorial"
|
||||
className="my-4 flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<Field label="Conteúdo">
|
||||
<select
|
||||
className={control}
|
||||
name="kind"
|
||||
defaultValue={filters.kind ?? ''}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="article">Artigos</option>
|
||||
<option value="legislation">Legislação</option>
|
||||
<option value="document">Documentos</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Estado da edição">
|
||||
<select
|
||||
className={control}
|
||||
name="state"
|
||||
defaultValue={filters.state ?? ''}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{Object.entries(states).map(
|
||||
([state, label]) => (
|
||||
<option key={state} value={state}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</Field>
|
||||
<button className={button}>Filtrar fila</button>
|
||||
</form>
|
||||
<section aria-label="Fila editorial" className="space-y-3">
|
||||
{items.data.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="rounded border p-4"
|
||||
>
|
||||
<Link
|
||||
className="font-semibold underline"
|
||||
href={`/management/editorial/resources/${item.id}`}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
<p>
|
||||
Edição {item.revision_no} ·{' '}
|
||||
{states[item.state] ?? item.state} ·
|
||||
Responsável:{' '}
|
||||
{item.responsible ?? 'Por atribuir'}
|
||||
</p>
|
||||
<p>
|
||||
{item.published
|
||||
? 'Versão pública disponível'
|
||||
: 'Sem versão pública'}
|
||||
{item.legal_required &&
|
||||
' · Exige validação jurídica'}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<nav aria-label="Paginação" className="flex gap-4">
|
||||
{items.current_page > 1 && (
|
||||
<Link href={pageUrl(items.current_page - 1)}>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
<span>
|
||||
Página {items.current_page} de {items.last_page}
|
||||
</span>
|
||||
{items.current_page < items.last_page && (
|
||||
<Link href={pageUrl(items.current_page + 1)}>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
<section aria-label="Notificações editoriais">
|
||||
<h2 className="font-semibold">Notificações</h2>
|
||||
{notices.map((n) => (
|
||||
<p key={n.id}>
|
||||
<Link className="underline" href={n.href}>
|
||||
{eventTitles[n.action] ?? 'Alteração editorial'} ·{' '}
|
||||
{n.reference}
|
||||
</Link>
|
||||
{!n.read_at && (
|
||||
<button
|
||||
className="ml-3 underline"
|
||||
onClick={() =>
|
||||
router.post(
|
||||
`/management/editorial/notices/${n.id}/read`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Marcar como lida
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
</Management>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export default function ArticleEditor({
|
||||
source,
|
||||
images,
|
||||
revisions,
|
||||
workflow,
|
||||
workflowEnabled = false,
|
||||
}: {
|
||||
resource: {
|
||||
id: number;
|
||||
@@ -36,6 +38,8 @@ export default function ArticleEditor({
|
||||
change_type: string;
|
||||
created_at: string;
|
||||
}[];
|
||||
workflowEnabled?: boolean;
|
||||
workflow?: { id: string; state: string; revision_no: number } | null;
|
||||
}) {
|
||||
const initial = () => ({
|
||||
body: content?.body ?? '',
|
||||
@@ -106,9 +110,21 @@ export default function ArticleEditor({
|
||||
<Management title={`Editar artigo — ${resource.title}`}>
|
||||
<p>
|
||||
O original, a origem e as versões permanecem na biblioteca.
|
||||
Guardar cria uma revisão editorial e retira a publicação até à
|
||||
sua acção expressa no acervo.
|
||||
{workflowEnabled
|
||||
? 'Guardar prepara uma edição privada e conserva a versão pública até à publicação explícita da nova edição aprovada.'
|
||||
: 'No editor anterior, guardar retira a publicação até à sua acção expressa no acervo.'}
|
||||
</p>
|
||||
{workflow && (
|
||||
<p>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/management/editorial/resources/${resource.id}`}
|
||||
>
|
||||
Conferir edição {workflow.revision_no}, revisão e
|
||||
publicação
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
<p role="status">
|
||||
Revisão guardada {resource.metadata_version} ·{' '}
|
||||
{resource.published ? 'Publicado' : 'Rascunho privado'}
|
||||
|
||||
@@ -166,7 +166,9 @@ export default function Manage({
|
||||
editorialScopeOnly = false,
|
||||
managementFilters = {},
|
||||
associationOptions = {},
|
||||
editorialWorkflowEnabled = false,
|
||||
}: {
|
||||
editorialWorkflowEnabled?: boolean;
|
||||
managementFilters?: { q?: string; state?: string; intent?: string };
|
||||
associationOptions?: Record<
|
||||
string,
|
||||
@@ -984,20 +986,24 @@ export default function Manage({
|
||||
/>
|
||||
Destacar como essencial na página inicial
|
||||
</label>
|
||||
<label className="flex gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.data.independent_review}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'independent_review',
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Foi realizada revisão independente por outro
|
||||
gestor
|
||||
</label>
|
||||
{!editorialWorkflowEnabled && (
|
||||
<label className="flex gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
form.data.independent_review
|
||||
}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'independent_review',
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Foi realizada revisão independente por
|
||||
outro gestor
|
||||
</label>
|
||||
)}
|
||||
<fieldset className="space-y-3 rounded border p-4">
|
||||
<legend className="font-semibold">
|
||||
Relações com etapas, módulos e
|
||||
@@ -1401,7 +1407,7 @@ export default function Manage({
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{disclosureAuthorization && (
|
||||
{disclosureAuthorization && !editorialWorkflowEnabled && (
|
||||
<section
|
||||
className="space-y-3 rounded border border-emerald-700 bg-emerald-50 p-5"
|
||||
aria-labelledby="disclosure-authorization-title"
|
||||
@@ -1684,31 +1690,41 @@ export default function Manage({
|
||||
>
|
||||
Ler / pré-visualizar artigo
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
setActionError('');
|
||||
router.post(
|
||||
`/management/portal-services/resources/${r.id}`,
|
||||
{ published: !r.published },
|
||||
{
|
||||
preserveScroll: true,
|
||||
onError: (errors) =>
|
||||
setActionError(
|
||||
Object.values(
|
||||
errors,
|
||||
)[0] ??
|
||||
'A transição foi recusada; confirme o estado do ficheiro, fonte e direitos.',
|
||||
),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{r.published
|
||||
? 'Retirar da publicação'
|
||||
: 'Publicar'}
|
||||
</button>
|
||||
{editorialWorkflowEnabled && (
|
||||
<a
|
||||
className={button}
|
||||
href={`/management/editorial/resources/${r.id}`}
|
||||
>
|
||||
Revisão, aprovação e publicação
|
||||
</a>
|
||||
)}
|
||||
{(!editorialWorkflowEnabled || r.published) && (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
setActionError('');
|
||||
router.post(
|
||||
`/management/portal-services/resources/${r.id}`,
|
||||
{ published: !r.published },
|
||||
{
|
||||
preserveScroll: true,
|
||||
onError: (errors) =>
|
||||
setActionError(
|
||||
Object.values(
|
||||
errors,
|
||||
)[0] ??
|
||||
'A transição foi recusada; confirme o estado do ficheiro, fonte e direitos.',
|
||||
),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{r.published
|
||||
? 'Retirar da publicação'
|
||||
: 'Publicar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{actionError && <p role="alert">{actionError}</p>}
|
||||
</article>
|
||||
|
||||
@@ -37,6 +37,7 @@ use App\Http\Controllers\OpportunityManagementController;
|
||||
use App\Http\Controllers\OpportunityOriginReviewController;
|
||||
use App\Http\Controllers\OpportunityPromotionController;
|
||||
use App\Http\Controllers\OpportunityResourceController;
|
||||
use App\Http\Controllers\PortalEditorialWorkflowController;
|
||||
use App\Http\Controllers\PortalManagementController;
|
||||
use App\Http\Controllers\PortalProfileController;
|
||||
use App\Http\Controllers\PortalProfileInstallationController;
|
||||
@@ -95,6 +96,11 @@ Route::get('/explorar/{sectorSlug}', [LegacyContentController::class, 'landing']
|
||||
Route::get('/{legacySlug}', [LegacyContentController::class, 'article'])->whereIn('legacySlug', array_keys(config('legacy_content.articles', [])));
|
||||
Route::post('/apoio', [PortalServicesController::class, 'submit'])->middleware('throttle:10,1')->name('services.submit');
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/management/editorial', [PortalEditorialWorkflowController::class, 'index'])->name('editorial.workflow');
|
||||
Route::get('/management/editorial/resources/{id}', [PortalEditorialWorkflowController::class, 'show'])->whereNumber('id');
|
||||
Route::post('/management/editorial/resources/{id}/editions/{edition}', [PortalEditorialWorkflowController::class, 'transition'])->whereNumber('id')->whereUuid('edition');
|
||||
Route::get('/management/editorial/resources/{id}/editions/{edition}/files/{document}', [PortalEditorialWorkflowController::class, 'file'])->whereNumber('id')->whereUuid(['edition', 'document']);
|
||||
Route::post('/management/editorial/notices/{notice}/read', [PortalEditorialWorkflowController::class, 'markRead'])->whereNumber('notice');
|
||||
Route::get('/gestao-conteudos', fn (Request $request) => Inertia::render('management/content-hub', app(ContentManagement::class)->view($request->user())))->name('content-management');
|
||||
Route::get('/balcao-virtual', [PortalServicesController::class, 'page'])->defaults('page', 'desk')->name('services.desk');
|
||||
Route::post('/parcerias', [PortalServicesController::class, 'partnership'])->middleware('throttle:10,1')->name('services.partnership');
|
||||
|
||||
@@ -160,6 +160,82 @@ class OpportunityCanonicalWorkflowTest extends TestCase
|
||||
$this->assertNotNull($response['materiality']);
|
||||
}
|
||||
|
||||
public function test_phase_one_requires_a_new_legal_decision_for_each_content_and_attachment_version(): void
|
||||
{
|
||||
config(['editorial_workflow.enabled' => true]);
|
||||
$this->withoutVite();
|
||||
[$id, $promoter, $validator, $publisher, $payload, $document] = $this->fixture();
|
||||
$this->action($promoter, $id, 'submit');
|
||||
foreach (['property_review', 'registry_urban_review', 'building_compliance_review'] as $gate) {
|
||||
$this->action($validator, $id, 'review_gate', ['gate' => $gate, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]);
|
||||
}
|
||||
$this->action($validator, $id, 'approve');
|
||||
$signed = Opportunity::findOrFail($id)->workflow_context['reviews']['registry_urban_review'];
|
||||
$this->action($promoter, $id, 'request_revision');
|
||||
$payload['official_title'] .= ' — edição privada da Fase 1';
|
||||
$this->loginFixture($promoter)->putJson('/minhas-oportunidades/'.$id.'/draft', ['etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id)), 'canonical' => $payload, 'reason' => self::REASON])->assertOk();
|
||||
$this->action($promoter, $id, 'submit');
|
||||
$context = Opportunity::findOrFail($id)->workflow_context;
|
||||
$this->assertContains('registry_urban_review', $context['materiality']['reopened']);
|
||||
$this->assertArrayNotHasKey('registry_urban_review', $context['reviews']);
|
||||
$this->assertSame($signed['hash'], $context['decision_history'][0]['reviews']['registry_urban_review']['hash']);
|
||||
$this->action($validator, $id, 'approve', [], 422);
|
||||
$this->action($validator, $id, 'review_gate', ['gate' => 'registry_urban_review', 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]);
|
||||
$this->action($validator, $id, 'approve');
|
||||
$this->action($publisher, $id, 'assign_publication', ['assignee_user_id' => $publisher->id]);
|
||||
$file = DB::table('opportunity_documents')->where('id', $document)->value('document_id');
|
||||
DB::table('documents')->where('id', $file)->increment('version_no');
|
||||
$this->action($publisher, $id, 'publish', [], 409);
|
||||
}
|
||||
|
||||
public function test_phase_one_activation_preserves_an_approved_legacy_submission_without_rewriting_its_hash(): void
|
||||
{
|
||||
config(['editorial_workflow.enabled' => false]);
|
||||
$this->withoutVite();
|
||||
[$id, $promoter, $validator, $publisher, , $document] = $this->fixture();
|
||||
$this->action($promoter, $id, 'submit');
|
||||
foreach (['property_review', 'registry_urban_review', 'building_compliance_review'] as $gate) {
|
||||
$this->action($validator, $id, 'review_gate', ['gate' => $gate, 'gate_decision' => 'approved', 'evidence_document_ids' => [$document]]);
|
||||
}
|
||||
$this->action($validator, $id, 'approve');
|
||||
$approved = Opportunity::findOrFail($id)->workflow_context['approval']['hash'];
|
||||
config(['editorial_workflow.enabled' => true]);
|
||||
$this->action($publisher, $id, 'assign_publication', ['assignee_user_id' => $publisher->id]);
|
||||
$this->action($publisher, $id, 'publish');
|
||||
$record = Opportunity::findOrFail($id);
|
||||
$this->assertSame('published', $record->status);
|
||||
$this->assertSame($approved, $record->workflow_context['approval']['hash']);
|
||||
$this->assertArrayNotHasKey('editorial_version_binding', $record->workflow_context);
|
||||
}
|
||||
|
||||
public function test_phase_one_opportunity_notices_follow_current_access_and_do_not_grant_resource_access(): void
|
||||
{
|
||||
config(['editorial_workflow.enabled' => true]);
|
||||
$this->withoutVite();
|
||||
[$id, $promoter, $validator] = $this->fixture();
|
||||
$this->action($promoter, $id, 'submit');
|
||||
$this->action($validator, $id, 'start_review');
|
||||
$notice = DB::table('portal_editorial_notifications')->where('user_id', $promoter->id)->where('opportunity_id', $id)->first();
|
||||
$this->assertNotNull($notice);
|
||||
$this->assertNotNull($notice->opportunity_version);
|
||||
$data = $this->loginFixture($promoter)->getJson('/management/editorial')->assertOk()->assertJsonPath('resourceAccess', false)->assertJsonPath('items.total', 0)->json();
|
||||
$this->assertNotEmpty($data['notices']);
|
||||
$this->assertSame('/minhas-oportunidades/'.$id, $data['notices'][0]['href']);
|
||||
$this->postJson('/management/editorial/notices/'.$notice->id.'/read')->assertOk();
|
||||
$this->assertNotNull(DB::table('portal_editorial_notifications')->where('id', $notice->id)->value('read_at'));
|
||||
$administrator = User::factory()->create();
|
||||
config(['portal.managers' => [$administrator->email]]);
|
||||
DB::table('portal_editorial_notifications')->insert(['user_id' => $administrator->id, 'edition_id' => null, 'opportunity_id' => $id,
|
||||
'opportunity_version' => $notice->opportunity_version, 'action' => 'start_review', 'created_at' => now()]);
|
||||
$this->loginFixture($administrator)->getJson('/management/editorial')->assertOk()->assertJsonPath('resourceAccess', true)->assertJsonPath('notices', [])->assertJsonPath('opportunityPending', 0);
|
||||
$adminNotice = DB::table('portal_editorial_notifications')->where('user_id', $administrator->id)->value('id');
|
||||
$this->postJson('/management/editorial/notices/'.$adminNotice.'/read')->assertNotFound();
|
||||
$this->loginFixture($promoter);
|
||||
DB::table('portal_role_assignments')->where('user_id', $promoter->id)->update(['revoked_at' => now()]);
|
||||
$this->getJson('/management/editorial')->assertForbidden();
|
||||
$this->postJson('/management/editorial/notices/'.$notice->id.'/read')->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_geometry_order_is_material_and_object_key_order_is_not(): void
|
||||
{
|
||||
$materiality = app(OpportunityReviewMateriality::class);
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\PortalEditorialWorkflow;
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\Support\AuthenticatesQaActors;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class PortalEditorialWorkflowTest extends TestCase
|
||||
{
|
||||
use AuthenticatesQaActors, DatabaseTransactions;
|
||||
|
||||
private User $author;
|
||||
|
||||
private User $reviewer;
|
||||
|
||||
private User $legal;
|
||||
|
||||
private User $publisher;
|
||||
|
||||
private int $resource;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
|
||||
if (! Schema::hasTable('portal_editorial_editions')) {
|
||||
(require database_path('migrations/2026_11_13_220000_create_portal_editorial_editions.php'))->up();
|
||||
}
|
||||
$this->assertTrue(Schema::hasColumns('portal_editorial_editions', ['payload', 'content_hash', 'state', 'version', 'approval', 'legal_validation']));
|
||||
if (! Schema::hasColumn('portal_editorial_notifications', 'opportunity_id')) {
|
||||
(require database_path('migrations/2026_11_13_223000_extend_editorial_notifications_to_opportunities.php'))->up();
|
||||
}
|
||||
config(['editorial_workflow.enabled' => true, 'editorial_workflow.independent_approval.kinds' => ['article', 'legislation', 'document']]);
|
||||
$this->withoutVite();
|
||||
Storage::fake('local');
|
||||
[$this->author, $this->reviewer, $this->legal, $this->publisher] = User::factory()->count(4)->create()->all();
|
||||
config(['portal.managers' => [$this->author->email, $this->reviewer->email, $this->legal->email, $this->publisher->email], 'editorial_workflow.legal_reviewers' => [$this->legal->id]]);
|
||||
$document = $this->document('Conteúdo público original.');
|
||||
$this->resource = DB::table('portal_resources')->insertGetId(['document_id' => $document, 'title' => 'Artigo sintético QA', 'category' => 'guide', 'topic' => 'qa', 'document_type' => 'guide',
|
||||
'published' => true, 'approved_by' => $this->reviewer->id, 'created_by' => $this->author->id, 'source_reference' => 'Fonte sintética de teste isolado', 'source_url' => 'https://example.invalid/source',
|
||||
'source_confirmation' => 'identified', 'rights_status' => 'authorized', 'independent_review' => true, 'metadata_version' => 1,
|
||||
'editorial_content' => json_encode(['body' => 'Conteúdo público original.', 'image_mode' => 'none', 'preserve_original_html' => false]), 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
private function document(string $body): string
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
$bytes = json_encode(['format' => 'investmoz-legacy-article-v1', 'content_html' => '<p>'.$body.'</p>']);
|
||||
$hash = hash('sha256', $bytes);
|
||||
Storage::disk('local')->put('qa/'.$id.'.json', $bytes);
|
||||
DB::table('documents')->insert(['id' => $id, 'file_name' => 'synthetic.json', 'media_type' => 'application/json', 'storage_key' => 'qa/'.$id.'.json',
|
||||
'sha256' => $hash, 'malware_scanned_sha256' => $hash, 'malware_scan_status' => 'clean', 'rights_status' => 'authorized', 'status' => 'active', 'data_classification' => 'public', 'source_system' => 'portal-services', 'uploaded_by' => $this->author->id]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function save(array $fields = []): \stdClass
|
||||
{
|
||||
$this->actingAsWithMfa($this->author);
|
||||
|
||||
$resource = DB::table('portal_resources')->where('id', $this->resource)->first();
|
||||
|
||||
return app(PortalEditorialWorkflow::class)->save($this->author, $this->resource, (int) app(PortalEditorialWorkflow::class)->workingResource($resource)->metadata_version, $fields);
|
||||
}
|
||||
|
||||
private function action(User $actor, \stdClass $edition, string $action, int $status = 200, array $extra = [])
|
||||
{
|
||||
$this->actingAsWithMfa($actor);
|
||||
$response = $this->postJson('/management/editorial/resources/'.$this->resource.'/editions/'.$edition->id, ['version' => $edition->version, 'action' => $action, 'reason' => 'Decisão sintética fundamentada QA', ...$extra])->assertStatus($status);
|
||||
|
||||
return $status === 200 ? DB::table('portal_editorial_editions')->where('id', $edition->id)->first() : $response;
|
||||
}
|
||||
|
||||
public function test_editing_and_private_preview_preserve_the_public_article_and_its_original_version(): void
|
||||
{
|
||||
$before = DB::table('portal_resources')->where('id', $this->resource)->first();
|
||||
$this->actingAsWithMfa($this->author)->put('/management/portal-services/resources/'.$this->resource.'/article', ['metadata_version' => 1, 'body' => 'Nova edição privada.', 'image_mode' => 'none'])->assertRedirect();
|
||||
$r = DB::table('portal_resources')->where('id', $this->resource)->first();
|
||||
$this->assertEquals($before, $r, 'The complete public resource must remain unchanged while a private edition is saved.');
|
||||
$this->assertTrue((bool) $r->published);
|
||||
$this->assertSame(1, (int) $r->metadata_version, 'A private edit must not change even the public metadata version.');
|
||||
$this->assertSame('Conteúdo público original.', json_decode($r->editorial_content, true)['body']);
|
||||
$this->assertSame(2, DB::table('portal_editorial_editions')->where('resource_id', $this->resource)->count());
|
||||
$this->get('/management/editorial/resources/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('current', true)->where('canApprove', false)->where('resource.public_edition_no', 1));
|
||||
$this->get('/management/portal-services/resources/'.$this->resource.'/article/preview')->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Nova edição privada.'));
|
||||
$this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Conteúdo público original.'));
|
||||
$this->put('/management/portal-services/resources/'.$this->resource.'/article', ['metadata_version' => 1, 'body' => 'Resposta atrasada.', 'image_mode' => 'none'])->assertConflict();
|
||||
$this->assertSame(2, DB::table('portal_editorial_editions')->where('resource_id', $this->resource)->count());
|
||||
$e = app(PortalEditorialWorkflow::class)->latest($this->resource);
|
||||
$e = $this->action($this->author, $e, 'submit');
|
||||
$e = $this->action($this->reviewer, $e, 'start_review');
|
||||
$e = $this->action($this->reviewer, $e, 'approve');
|
||||
$this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Conteúdo público original.'));
|
||||
$this->action($this->publisher, $e, 'publish');
|
||||
$this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Nova edição privada.'));
|
||||
}
|
||||
|
||||
public function test_legal_approval_and_publication_are_separate_version_bound_operations(): void
|
||||
{
|
||||
$e = $this->save(['title' => 'Legislação revista QA', 'legislation' => true, 'document_type' => 'legislation', 'source_confirmation' => 'official_confirmed']);
|
||||
$e = $this->action($this->author, $e, 'submit', extra: ['assigned_to' => $this->reviewer->id]);
|
||||
$e = $this->action($this->reviewer, $e, 'start_review');
|
||||
$this->action($this->author, $e, 'approve', 403);
|
||||
$this->action($this->reviewer, $e, 'approve', 422);
|
||||
$this->action($this->reviewer, $e, 'legal_validate', 403);
|
||||
$e = $this->action($this->legal, $e, 'legal_validate');
|
||||
$e = $this->action($this->reviewer, $e, 'approve');
|
||||
$this->assertSame('Artigo sintético QA', DB::table('portal_resources')->where('id', $this->resource)->value('title'));
|
||||
$this->assertSame('approved', $e->state);
|
||||
$approved = $e;
|
||||
$e = $this->action($this->publisher, $e, 'publish');
|
||||
$this->assertSame('published', $e->state);
|
||||
$this->assertSame('Legislação revista QA', DB::table('portal_resources')->where('id', $this->resource)->value('title'));
|
||||
$this->action($this->publisher, $approved, 'publish', 409);
|
||||
$draft = $this->save(['title' => 'Alteração posterior privada']);
|
||||
$this->assertNull($draft->approval);
|
||||
$this->assertNull($draft->legal_validation);
|
||||
$this->assertSame('Legislação revista QA', DB::table('portal_resources')->where('id', $this->resource)->value('title'));
|
||||
$this->actingAsWithMfa($this->author)->getJson('/management/editorial/resources/'.$this->resource)->assertOk()->assertJsonPath('edition.id', $draft->id)->assertJsonPath('resource.published', true);
|
||||
$this->assertGreaterThan(0, DB::table('portal_editorial_notifications')->where('user_id', $this->author->id)->count());
|
||||
$this->assertGreaterThan(0, DB::table('audit_events')->where('action', 'editorial_publish')->count());
|
||||
$this->getJson('/management/editorial/resources/'.$this->resource.'?edition='.$approved->id)->assertOk()->assertJsonPath('current', false)->assertJsonPath('edition.payload.resource.title', 'Legislação revista QA');
|
||||
}
|
||||
|
||||
public function test_replacing_an_attachment_invalidates_approval_and_private_file_routes_are_scoped(): void
|
||||
{
|
||||
$doc = $this->document('Anexo novo, apenas na edição privada.');
|
||||
$e = $this->save(['document_id' => $doc]);
|
||||
$url = '/management/editorial/resources/'.$this->resource.'/editions/'.$e->id.'/files/'.$doc;
|
||||
$this->get($url)->assertOk()->assertHeader('Cache-Control', 'no-store, private');
|
||||
$outsider = User::factory()->create();
|
||||
$this->actingAsWithMfa($outsider)->getJson($url)->assertForbidden();
|
||||
$e = $this->action($this->author, $e, 'submit');
|
||||
$e = $this->action($this->reviewer, $e, 'start_review');
|
||||
$e = $this->action($this->reviewer, $e, 'approve');
|
||||
DB::table('documents')->where('id', $doc)->increment('version_no');
|
||||
$this->action($this->publisher, $e, 'publish', 409);
|
||||
$this->actingAsWithMfa($this->author)->getJson($url)->assertConflict();
|
||||
$this->assertNotSame($doc, DB::table('portal_resources')->where('id', $this->resource)->value('document_id'));
|
||||
}
|
||||
|
||||
public function test_returns_rejection_archive_permissions_and_legacy_publication_cannot_bypass_review(): void
|
||||
{
|
||||
$e = $this->save(['title' => 'Edição para devolver']);
|
||||
$e = $this->action($this->author, $e, 'submit');
|
||||
$e = $this->action($this->reviewer, $e, 'start_review');
|
||||
$e = $this->action($this->reviewer, $e, 'return');
|
||||
$this->assertSame('returned', $e->state);
|
||||
$e = $this->action($this->author, $e, 'submit');
|
||||
$e = $this->action($this->reviewer, $e, 'start_review');
|
||||
$e = $this->action($this->reviewer, $e, 'reject');
|
||||
$e = $this->action($this->author, $e, 'archive');
|
||||
$this->assertTrue((bool) DB::table('portal_resources')->where('id', $this->resource)->value('published'));
|
||||
$this->actingAsWithMfa($this->publisher)->post('/management/portal-services/resources/'.$this->resource, ['published' => true])->assertConflict();
|
||||
$this->actingAs(User::factory()->create())->getJson('/management/editorial')->assertForbidden();
|
||||
$this->actingAs($this->author)->withSession(['portal.mfa_at' => 0])->postJson('/management/editorial/resources/'.$this->resource.'/editions/'.$e->id, ['action' => 'submit', 'version' => $e->version, 'reason' => 'MFA expirado'])->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_document_metadata_replacement_remains_private_and_is_recovered_with_its_original_file(): void
|
||||
{
|
||||
$doc = $this->document('Documento de substituição privado.');
|
||||
DB::table('documents')->where('id', $doc)->update(['data_classification' => 'internal']);
|
||||
$fields = ['document_id' => $doc, 'title' => 'Documento revisto QA', 'description' => 'Descrição privada', 'category' => 'guide', 'topic' => 'qa',
|
||||
'document_type' => 'guide', 'language' => 'pt', 'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'not_applicable',
|
||||
'rights_status' => 'authorized', 'independent_review' => true, 'source_reference' => 'Ensaio sintético de referência documental', 'source_url' => 'https://example.invalid/document',
|
||||
'legislation' => false, 'essential' => false, 'data_classification' => 'public', 'metadata_version' => 1, 'issuer' => 'Emissor sintético QA'];
|
||||
$before = DB::table('portal_resources')->where('id', $this->resource)->first();
|
||||
$this->actingAsWithMfa($this->author)->put('/management/portal-services/resources/'.$this->resource, $fields)->assertSessionHasNoErrors()->assertRedirect();
|
||||
$this->assertSame($before->document_id, DB::table('portal_resources')->where('id', $this->resource)->value('document_id'));
|
||||
$this->assertSame('internal', DB::table('documents')->where('id', $doc)->value('data_classification'));
|
||||
$this->getJson('/management/editorial/resources/'.$this->resource)->assertOk()->assertJsonPath('edition.payload.resource.title', $fields['title'])->assertJsonPath('edition.payload.resource.document_id', $doc);
|
||||
$this->get('/documentos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('resource.title', $before->title));
|
||||
$e = app(PortalEditorialWorkflow::class)->latest($this->resource);
|
||||
$e = $this->action($this->author, $e, 'submit');
|
||||
$e = $this->action($this->reviewer, $e, 'start_review');
|
||||
$e = $this->action($this->reviewer, $e, 'approve');
|
||||
$e = $this->action($this->publisher, $e, 'publish');
|
||||
$this->assertSame('public', DB::table('documents')->where('id', $doc)->value('data_classification'));
|
||||
$this->assertSame($doc, DB::table('portal_resources')->where('id', $this->resource)->value('document_id'));
|
||||
$this->assertSame('Emissor sintético QA', DB::table('documents')->where('id', $doc)->value('issuer'));
|
||||
}
|
||||
|
||||
public function test_notice_receipt_never_grants_access_and_revocation_blocks_publication(): void
|
||||
{
|
||||
$e = $this->save();
|
||||
$e = $this->action($this->author, $e, 'submit', extra: ['assigned_to' => $this->reviewer->id]);
|
||||
$this->actingAsWithMfa($this->reviewer)->getJson('/management/editorial')->assertOk()->assertJsonPath('items.data.0.edition_id', $e->id);
|
||||
$this->getJson('/management/editorial?state=submitted&kind=article')->assertOk()->assertJsonPath('items.data.0.edition_id', $e->id);
|
||||
$this->getJson('/management/editorial?state=approved')->assertOk()->assertJsonPath('items.total', 0);
|
||||
$notice = DB::table('portal_editorial_notifications')->where('user_id', $this->reviewer->id)->value('id');
|
||||
$this->postJson('/management/editorial/notices/'.$notice.'/read')->assertOk();
|
||||
$this->assertNotNull(DB::table('portal_editorial_notifications')->where('id', $notice)->value('read_at'));
|
||||
$this->actingAsWithMfa($this->author)->postJson('/management/editorial/notices/'.$notice.'/read')->assertNotFound();
|
||||
$outsider = User::factory()->create();
|
||||
DB::table('portal_editorial_notifications')->insert(['edition_id' => $e->id, 'user_id' => $outsider->id, 'action' => 'submit', 'created_at' => now()]);
|
||||
$this->actingAsWithMfa($outsider)->getJson('/management/editorial')->assertForbidden();
|
||||
$this->getJson('/management/editorial/resources/'.$this->resource)->assertForbidden();
|
||||
$e = $this->action($this->reviewer, $e, 'start_review');
|
||||
$e = $this->action($this->reviewer, $e, 'approve');
|
||||
config(['portal.managers' => [$this->author->email, $this->publisher->email, $this->legal->email]]);
|
||||
$this->action($this->publisher, $e, 'publish', 403);
|
||||
$this->assertSame('Artigo sintético QA', DB::table('portal_resources')->where('id', $this->resource)->value('title'));
|
||||
}
|
||||
|
||||
public function test_all_contributors_are_excluded_from_independent_approval_and_modified_bytes_block_review(): void
|
||||
{
|
||||
$this->save(['description' => 'Contributo deliberado da autora.']);
|
||||
$this->actingAsWithMfa($this->reviewer);
|
||||
$e = app(PortalEditorialWorkflow::class)->save($this->reviewer, $this->resource, 2, ['title' => 'Edição colaborativa']);
|
||||
$e = $this->action($this->reviewer, $e, 'submit');
|
||||
$e = $this->action($this->legal, $e, 'start_review');
|
||||
$this->action($this->author, $e, 'approve', 403);
|
||||
$this->action($this->reviewer, $e, 'approve', 403);
|
||||
$e = $this->action($this->legal, $e, 'approve');
|
||||
$this->assertSame('approved', $e->state);
|
||||
$doc = $this->document('Anexo íntegro inicial.');
|
||||
$e = $this->save(['document_id' => $doc]);
|
||||
$key = DB::table('documents')->where('id', $doc)->value('storage_key');
|
||||
Storage::disk('local')->put($key, 'Alteração dos bytes sem actualizar os metadados.');
|
||||
$this->action($this->author, $e, 'submit', 404);
|
||||
$this->getJson('/management/editorial/resources/'.$this->resource)->assertOk()->assertJsonPath('edition.fresh', false);
|
||||
$this->get('/artigos/'.$this->resource)->assertOk()->assertInertia(fn (Assert $p) => $p->where('article.body', 'Conteúdo público original.'));
|
||||
}
|
||||
|
||||
public function test_new_document_is_prepared_privately_without_disclosure_or_direct_publication(): void
|
||||
{
|
||||
$doc = $this->document('Documento novo não publicado.');
|
||||
DB::table('documents')->where('id', $doc)->update(['data_classification' => 'internal']);
|
||||
$marker = 'SYNTHEDITORIAL'.Str::random(10);
|
||||
$this->actingAsWithMfa($this->author)->post('/management/portal-services/resources', ['document_id' => $doc, 'title' => $marker, 'category' => 'guide', 'topic' => 'qa',
|
||||
'document_type' => 'guide', 'language' => 'pt', 'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'not_applicable', 'rights_status' => 'authorized',
|
||||
'independent_review' => true, 'source_reference' => 'Fonte sintética QA, sem divulgação', 'source_url' => 'https://example.invalid/source', 'legislation' => false, 'essential' => false, 'data_classification' => 'public'])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$id = DB::table('portal_resources')->where('title', $marker)->value('id');
|
||||
$this->assertNotNull($id);
|
||||
$this->assertSame('draft', app(PortalEditorialWorkflow::class)->latest($id)->state);
|
||||
$this->assertFalse((bool) DB::table('portal_resources')->where('id', $id)->value('published'));
|
||||
$this->assertSame('internal', DB::table('documents')->where('id', $doc)->value('data_classification'));
|
||||
$this->get('/documentos/'.$id.'/download')->assertNotFound();
|
||||
$this->get('/pesquisa?q='.$marker)->assertOk()->assertInertia(fn (Assert $p) => $p->where('results.total', 0));
|
||||
$this->resource = (int) $id;
|
||||
$e = app(PortalEditorialWorkflow::class)->latest($this->resource);
|
||||
$e = $this->action($this->author, $e, 'submit');
|
||||
$e = $this->action($this->reviewer, $e, 'start_review');
|
||||
$e = $this->action($this->reviewer, $e, 'approve');
|
||||
$this->get('/documentos/'.$id.'/download')->assertNotFound();
|
||||
$this->action($this->publisher, $e, 'publish');
|
||||
$this->get('/documentos/'.$id.'/download')->assertOk();
|
||||
}
|
||||
|
||||
public function test_migration_refuses_an_operational_target_and_destructive_rollback_with_history(): void
|
||||
{
|
||||
$this->save();
|
||||
$migration = require database_path('migrations/2026_11_13_220000_create_portal_editorial_editions.php');
|
||||
try {
|
||||
$migration->down();
|
||||
$this->fail('Existing editions must not be removed by rollback.');
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->assertStringContainsString('Preserve editions', $exception->getMessage());
|
||||
}
|
||||
config(['database.connections.pgsql.port' => 5432]);
|
||||
try {
|
||||
$migration->up();
|
||||
$this->fail('Operational-target schema change must be refused.');
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->assertStringContainsString('only in isolated QA', $exception->getMessage());
|
||||
} finally {
|
||||
config(['database.connections.pgsql.port' => 55441]);
|
||||
}
|
||||
$this->assertSame(2, DB::table('portal_editorial_editions')->where('resource_id', $this->resource)->count());
|
||||
$this->assertTrue((bool) DB::table('portal_resources')->where('id', $this->resource)->value('published'));
|
||||
}
|
||||
|
||||
public function test_missing_editorial_schema_blocks_writes_without_blocking_the_existing_dashboard(): void
|
||||
{
|
||||
(require database_path('migrations/2026_11_13_220000_create_portal_editorial_editions.php'))->down();
|
||||
$before = DB::table('portal_resources')->where('id', $this->resource)->first();
|
||||
$this->actingAsWithMfa($this->author)->get('/dashboard')->assertOk();
|
||||
$this->putJson('/management/portal-services/resources/'.$this->resource.'/article', ['metadata_version' => 1, 'body' => 'Não aplicar sem estrutura.', 'image_mode' => 'none'])->assertStatus(503);
|
||||
$this->postJson('/management/portal-services/resources/'.$this->resource, ['published' => true])->assertStatus(503);
|
||||
$this->assertEquals($before, DB::table('portal_resources')->where('id', $this->resource)->first());
|
||||
}
|
||||
|
||||
public function test_review_independence_fact_is_not_a_policy_and_cannot_be_claimed_by_the_author(): void
|
||||
{
|
||||
config(['editorial_workflow.independent_approval.kinds' => [], 'editorial_workflow.independent_approval.resources' => []]);
|
||||
$e = $this->save(['independent_review' => true]);
|
||||
$this->assertFalse(app(PortalEditorialWorkflow::class)->requiresIndependent($e));
|
||||
$this->assertFalse(app(PortalEditorialWorkflow::class)->payload($e)['resource']['independent_review']);
|
||||
$e = $this->action($this->author, $e, 'submit');
|
||||
$e = $this->action($this->author, $e, 'start_review');
|
||||
$e = $this->action($this->author, $e, 'approve');
|
||||
$this->action($this->author, $e, 'publish');
|
||||
$this->assertFalse((bool) DB::table('portal_resources')->where('id', $this->resource)->value('independent_review'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\OpportunityReviewMateriality;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EditorialLegalVersionTest extends TestCase
|
||||
{
|
||||
public function test_legal_decisions_cannot_be_carried_to_a_different_content_version_in_phase_one(): void
|
||||
{
|
||||
$service = new OpportunityReviewMateriality;
|
||||
$before = ['hash' => str_repeat('a', 64), 'payload' => [], 'resources' => []];
|
||||
$after = [...$before, 'hash' => str_repeat('b', 64)];
|
||||
$review = ['decision' => 'approved', 'hash' => $before['hash'], 'scope_rule' => OpportunityReviewMateriality::VERSION];
|
||||
$review['scope_hash'] = $service->hash($before, 'land_legal_review', $review);
|
||||
$this->assertSame(['land_legal_review'], $service->reconcile($before, $after, ['land_legal_review' => $review], ['land_legal_review'])['retained']);
|
||||
$strict = $service->reconcile($before, $after, ['land_legal_review' => $review], ['land_legal_review'], exactLegalVersion: true);
|
||||
$this->assertSame([], $strict['reviews']);
|
||||
$this->assertSame(['land_legal_review'], $strict['reopened']);
|
||||
$this->assertFalse($service->matches($after, 'land_legal_review', [...$review, 'accepted_submission_hash' => $after['hash']], true));
|
||||
$this->assertTrue($service->matches($before, 'land_legal_review', $review, true));
|
||||
$this->assertSame($before['hash'], $review['hash']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import ts from 'typescript';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const source = fs.readFileSync(
|
||||
new URL(
|
||||
'../../resources/js/pages/management/editorial-edition.tsx',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const code = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
},
|
||||
}).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('require', 'module', 'exports', code)(
|
||||
(name) => {
|
||||
if (name === '@inertiajs/react')
|
||||
return {
|
||||
Link: ({ href, children, ...rest }) =>
|
||||
React.createElement('a', { href, ...rest }, children),
|
||||
useForm: (data) => ({
|
||||
data,
|
||||
errors: {},
|
||||
processing: false,
|
||||
setData() {},
|
||||
transform() {},
|
||||
post() {},
|
||||
}),
|
||||
};
|
||||
if (name === './shared')
|
||||
return {
|
||||
Management: ({ children }) =>
|
||||
React.createElement('main', null, children),
|
||||
Field: ({ label, children }) =>
|
||||
React.createElement('label', null, label, children),
|
||||
control: '',
|
||||
button: '',
|
||||
};
|
||||
if (name === './editorial-workflow')
|
||||
return {
|
||||
states: {
|
||||
draft: 'Rascunho',
|
||||
approved: 'Aprovado',
|
||||
in_review: 'Em revisão',
|
||||
},
|
||||
};
|
||||
return require(name);
|
||||
},
|
||||
module,
|
||||
module.exports,
|
||||
);
|
||||
const Page = module.exports.default;
|
||||
const props = {
|
||||
resource: {
|
||||
id: 91,
|
||||
title: 'Artigo QA',
|
||||
published: true,
|
||||
public_edition_no: 1,
|
||||
},
|
||||
manager: true,
|
||||
legalReviewer: true,
|
||||
current: true,
|
||||
canApprove: true,
|
||||
independentRequired: true,
|
||||
history: [],
|
||||
editions: [{ id: 'qa-edition', revision_no: 2, state: 'draft' }],
|
||||
reviewers: [{ id: 5, name: 'Equipa de revisão' }],
|
||||
edition: {
|
||||
id: 'qa-edition',
|
||||
revision_no: 2,
|
||||
version: 1,
|
||||
state: 'draft',
|
||||
content_hash: 'a'.repeat(64),
|
||||
fresh: true,
|
||||
legal_required: false,
|
||||
approval: null,
|
||||
legal_validation: null,
|
||||
payload: {
|
||||
resource: {
|
||||
title: 'Novo título',
|
||||
editorial_content: JSON.stringify({
|
||||
body: '<script>não executar</script>',
|
||||
}),
|
||||
},
|
||||
files: [
|
||||
{
|
||||
id: 'qa-file',
|
||||
file_name: 'anexo.pdf',
|
||||
version_no: 2,
|
||||
sha256: 'b'.repeat(64),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const render = (changes = {}) =>
|
||||
renderToStaticMarkup(React.createElement(Page, { ...props, ...changes }));
|
||||
|
||||
test('draft preserves public version and offers submission without approval or publication', () => {
|
||||
const html = render();
|
||||
assert.match(html, /Versão pública: edição 1/);
|
||||
assert.match(html, /value="submit"/);
|
||||
assert.doesNotMatch(html, /value="(?:approve|publish)"/);
|
||||
assert.match(
|
||||
html,
|
||||
/href="\/management\/editorial\/resources\/91\/editions\/qa-edition\/files\/qa-file" target="_blank" rel="noopener noreferrer"/,
|
||||
);
|
||||
assert.match(html, /<script>/);
|
||||
assert.doesNotMatch(html, /<script>/);
|
||||
});
|
||||
test('approved edition offers explicit publication; stale files and historical versions cannot be published', () => {
|
||||
assert.match(
|
||||
render({
|
||||
edition: { ...props.edition, state: 'approved', approval: '{}' },
|
||||
}),
|
||||
/value="publish"/,
|
||||
);
|
||||
const stale = render({
|
||||
edition: { ...props.edition, state: 'approved', fresh: false },
|
||||
});
|
||||
assert.match(stale, /Os anexos mudaram/);
|
||||
assert.doesNotMatch(stale, /value="publish"/);
|
||||
const historical = render({ current: false });
|
||||
assert.match(historical, /Versão histórica para consulta/);
|
||||
assert.doesNotMatch(historical, /<form/);
|
||||
assert.match(historical, /edition=qa-edition/);
|
||||
});
|
||||
test('contributor does not see independent approval or legal-validation actions', () => {
|
||||
const html = render({
|
||||
canApprove: false,
|
||||
edition: { ...props.edition, state: 'in_review' },
|
||||
});
|
||||
assert.doesNotMatch(html, /value="(?:approve|legal_validate)"/);
|
||||
assert.match(html, /responsável independente/);
|
||||
});
|
||||
Reference in New Issue
Block a user