feat(editorial): preserve public portfolio fiches during private review
This commit is contained in:
@@ -13,6 +13,7 @@ use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestmentRelations;
|
||||
use App\Services\InvestmentZones;
|
||||
use App\Services\PortalAudit;
|
||||
use App\Services\PortalEditorialWorkflow;
|
||||
use App\Services\PublicSearchMetrics;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
@@ -99,6 +100,10 @@ class InvestmentPortfolioController extends Controller
|
||||
{
|
||||
$management = $request->routeIs('management.portfolio*');
|
||||
$record = ($management ? InvestmentDossier::query() : self::visible())->findOrFail($id);
|
||||
$editorial = $management && app(PortalEditorialWorkflow::class)->portfolioEnabled();
|
||||
if ($editorial) {
|
||||
$record = app(PortalEditorialWorkflow::class)->workingPortfolio($record);
|
||||
}
|
||||
$relatedIds = [];
|
||||
foreach ([['corridor_projects', 'corridor_id', 'project_id'], ['corridor_clusters', 'corridor_id', 'cluster_id'], ['cluster_projects', 'cluster_id', 'project_id']] as [$table, $left, $right]) {
|
||||
foreach ([[$left, $right], [$right, $left]] as [$origin, $destination]) {
|
||||
@@ -147,7 +152,7 @@ class InvestmentPortfolioController extends Controller
|
||||
->orderByDesc('updated_at')->limit(10)->get(['id', 'version', 'updated_at'])
|
||||
: collect();
|
||||
|
||||
return Inertia::render('portfolio/show', ['complements' => $management ? [] : app(InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
|
||||
return Inertia::render('portfolio/show', ['editorialEnabled' => $editorial, 'complements' => $management ? [] : app(InvestmentComplements::class)->options('portfolio:'.$record->id), 'record' => $record, 'related' => $related, 'management' => $management, 'zones' => $zones,
|
||||
'anchorProject' => $anchorProject, 'existingSubmissions' => $existingSubmissions,
|
||||
'competitiveness' => $record->cluster_id ? app(ClusterCompetitiveness::class)->publicData($record->cluster_id) : null,
|
||||
'geography' => app(InvestmentRelations::class)->dossier($record, $management),
|
||||
@@ -167,6 +172,11 @@ class InvestmentPortfolioController extends Controller
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'title' => ['required', 'string', 'max:255'], 'source' => ['required', 'string', 'max:2000'],
|
||||
'content' => ['required', 'array:name,description,location,region,sector,promotion,facts,partners,cta,anchors,gaps,priority,clusters,focus,notice,code'],
|
||||
'content.*' => ['nullable', 'string', 'max:15000']]);
|
||||
if (app(PortalEditorialWorkflow::class)->portfolioWrites($dossier->id)) {
|
||||
app(PortalEditorialWorkflow::class)->savePortfolio($request->user(), $dossier, (int) $data['version_no'], $data);
|
||||
|
||||
return back()->with('success', 'Edição privada guardada. A publicação anterior permanece disponível; continue no circuito editorial.');
|
||||
}
|
||||
DB::transaction(function () use ($request, $dossier, $data) {
|
||||
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
|
||||
abort_if($dossier->version_no !== (int) $data['version_no'], 409, 'A ficha foi alterada. Recarregue a página e reveja a versão actual antes de guardar.');
|
||||
@@ -179,6 +189,7 @@ class InvestmentPortfolioController extends Controller
|
||||
|
||||
public function publish(Request $request, InvestmentDossier $dossier): RedirectResponse
|
||||
{
|
||||
abort_if(app(PortalEditorialWorkflow::class)->portfolioWrites($dossier->id), 409, 'Use a aprovação e publicação separadas da edição no circuito editorial.');
|
||||
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'approval_evidence' => ['required', 'string', 'min:15', 'max:3000']]);
|
||||
DB::transaction(function () use ($request, $dossier, $data) {
|
||||
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
@@ -40,7 +40,8 @@ final class PortalEditorialWorkflowController extends Controller
|
||||
}
|
||||
$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();
|
||||
$destinations = app(ContentManagement::class)->access($actor);
|
||||
$data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $destinations['opportunities']['index'] ?? null, 'opportunityPending' => $workflow->opportunityPending($actor), 'clustersUrl' => $destinations['clusters']['index'] ?? null];
|
||||
$data = ['items' => $items, 'filters' => $filters, 'notices' => $workflow->notices($actor), 'resourceAccess' => $access->full($actor) || $access->editorial($actor), 'opportunitiesUrl' => $destinations['opportunities']['index'] ?? null, 'opportunityPending' => $workflow->opportunityPending($actor), 'clustersUrl' => $destinations['clusters']['index'] ?? null,
|
||||
'portfolioUrl' => $destinations['portfolio']['index'] ?? null, 'portfolioPending' => $workflow->portfolioPending($actor)];
|
||||
|
||||
return $this->screen($request, 'management/editorial-workflow', $data);
|
||||
}
|
||||
@@ -76,6 +77,34 @@ final class PortalEditorialWorkflowController extends Controller
|
||||
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial registada.');
|
||||
}
|
||||
|
||||
public function portfolio(Request $request, string $id, PortalEditorialWorkflow $workflow): Response
|
||||
{
|
||||
$workflow->authorizePortfolio($request->user(), $id);
|
||||
$record = DB::table('investment_dossiers')->where('id', $id)->first();
|
||||
abort_unless($record !== null, 404);
|
||||
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
|
||||
$latest = $workflow->latestPortfolio($id);
|
||||
$edition = $selected ? DB::table('portal_editorial_editions')->where('dossier_id', $id)->where('id', $selected)->first() : $latest;
|
||||
abort_if($selected && ! $edition, 404);
|
||||
$payload = $edition ? $workflow->payload($edition) : null;
|
||||
$independent = ! $edition || ! in_array($request->user()->id, $payload['contributors'], true);
|
||||
|
||||
return $this->screen($request, 'management/editorial-edition', ['subject' => 'portfolio',
|
||||
'resource' => ['id' => $id, 'title' => $record->title, 'published' => $record->publication_status === 'published', 'public_edition_no' => $record->publication_status === 'published' ? DB::table('portal_editorial_editions')->where('dossier_id', $id)->where('state', 'published')->max('revision_no') : null],
|
||||
'edition' => $edition ? [...get_object_vars($edition), 'payload' => $payload, 'fresh' => $edition->state === 'published' || $workflow->fresh($edition)] : null,
|
||||
'current' => $edition && $edition->id === $latest?->id, 'independentRequired' => true, 'canApprove' => $independent, 'manager' => true, 'legalReviewer' => false,
|
||||
'reviewers' => User::whereIn('email', config('portal.managers', []))->whereNotNull('email_verified_at')->get(['id', 'name']),
|
||||
'history' => DB::table('audit_events')->where('source_system', 'document-access-v1')->where('action', 'like', 'editorial_%')->where('after_data->dossier_id', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
|
||||
'editions' => DB::table('portal_editorial_editions')->where('dossier_id', $id)->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]);
|
||||
}
|
||||
|
||||
public function portfolioTransition(Request $request, string $id, string $edition, PortalEditorialWorkflow $workflow): Response
|
||||
{
|
||||
$result = $workflow->transition($request->user(), $id, $edition, $request->all(), true);
|
||||
|
||||
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial da carteira registada.');
|
||||
}
|
||||
|
||||
public function file(Request $request, int $id, string $edition, string $document, PortalEditorialWorkflow $workflow): Response
|
||||
{
|
||||
$workflow->authorize($request->user(), $id);
|
||||
|
||||
@@ -24,6 +24,9 @@ final class ContentManagement
|
||||
$editor = $documents->full($actor) || $documents->editorial($actor);
|
||||
$upload = $editor || $documents->limited($actor);
|
||||
$result = [];
|
||||
if (app(PortalEditorialWorkflow::class)->portfolioAvailable() && $actor->can('manage-portal') && ! $profiles->has($actor, 'auditor')) {
|
||||
$result['portfolio'] = ['index' => '/management/portfolio'];
|
||||
}
|
||||
if (app(ClusterCompetitiveness::class)->editorialEnabled() && $actor->can('manage-portal') && ! $profiles->has($actor, 'auditor')) {
|
||||
$result['clusters'] = ['index' => '/management/clusters'];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\InvestmentDossier;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
@@ -56,7 +57,7 @@ final class PortalEditorialWorkflow
|
||||
{
|
||||
$p = $this->payload($e);
|
||||
try {
|
||||
return hash_equals($e->content_hash, $this->hash($this->snapshot($p['resource'], $p['links'], $p['document_changes'], $p['contributors'], $p['policy'])));
|
||||
return hash_equals($e->content_hash, $this->hash($this->refreshPayload($e, $p)));
|
||||
} catch (HttpException $exception) {
|
||||
if (in_array($exception->getStatusCode(), [404, 422], true)) {
|
||||
return false;
|
||||
@@ -154,7 +155,14 @@ final class PortalEditorialWorkflow
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$this->authorize($actor, (int) $edition->resource_id);
|
||||
if ($edition->dossier_id ?? null) {
|
||||
if (! config('editorial_workflow.portfolio')) {
|
||||
return false;
|
||||
}
|
||||
$this->authorizePortfolio($actor, $edition->dossier_id);
|
||||
} else {
|
||||
$this->authorize($actor, (int) $edition->resource_id);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (HttpException $exception) {
|
||||
@@ -193,7 +201,7 @@ final class PortalEditorialWorkflow
|
||||
}
|
||||
$edition = $notice->edition_id ? DB::table('portal_editorial_editions')->where('id', $notice->edition_id)->first() : null;
|
||||
$result[] = ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at,
|
||||
'href' => $notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : '/management/editorial/resources/'.$edition->resource_id.'?edition='.$notice->edition_id, 'reference' => $notice->opportunity_id !== null ? 'Revisão '.$notice->opportunity_version : 'Edição '.$edition->revision_no];
|
||||
'href' => $notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : (($edition->dossier_id ?? null) ? '/management/editorial/portfolio/'.$edition->dossier_id : '/management/editorial/resources/'.$edition->resource_id).'?edition='.$notice->edition_id, 'reference' => $notice->opportunity_id !== null ? 'Revisão '.$notice->opportunity_version : 'Edição '.$edition->revision_no];
|
||||
if (count($result) === 50) {
|
||||
break;
|
||||
}
|
||||
@@ -222,7 +230,7 @@ final class PortalEditorialWorkflow
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor), 'unread' => $this->unreadCount($actor)];
|
||||
return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor) + $this->portfolioPending($actor), 'unread' => $this->unreadCount($actor)];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $resource
|
||||
@@ -266,6 +274,141 @@ final class PortalEditorialWorkflow
|
||||
return hash('sha256', json_encode(Arr::sortRecursive($payload), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function portfolioEnabled(): bool
|
||||
{
|
||||
if (! config('editorial_workflow.portfolio')) {
|
||||
return false;
|
||||
}
|
||||
abort_unless($this->portfolioAvailable(), 503, 'Estrutura editorial da carteira ainda não instalada.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function portfolioAvailable(): bool
|
||||
{
|
||||
return (bool) config('editorial_workflow.portfolio') && $this->available() && Schema::hasColumn('portal_editorial_editions', 'dossier_id');
|
||||
}
|
||||
|
||||
public function portfolioPending(User $actor): int
|
||||
{
|
||||
if (! $this->portfolioAvailable() || ! $actor->can('manage-portal') || app(PortalProfileAccess::class)->has($actor, 'auditor')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return DB::table('portal_editorial_editions as e')->join('investment_dossiers as d', 'd.id', '=', 'e.dossier_id')->whereNull('d.deleted_at')
|
||||
->whereNotIn('e.state', ['published', 'archived'])->whereNotExists(fn ($q) => $q->selectRaw('1')->from('portal_editorial_editions as later')->whereColumn('later.dossier_id', 'e.dossier_id')->whereColumn('later.revision_no', '>', 'e.revision_no'))->count();
|
||||
}
|
||||
|
||||
public function portfolioWrites(string $id): bool
|
||||
{
|
||||
if ($this->portfolioEnabled()) {
|
||||
return true;
|
||||
}
|
||||
abort_if(Schema::hasColumn('portal_editorial_editions', 'dossier_id') && DB::table('portal_editorial_editions')->where('dossier_id', $id)->exists(), 503, 'Reactive o circuito editorial para editar esta ficha; a publicação anterior está preservada.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function authorizePortfolio(User $actor, string $id, bool $write = false): void
|
||||
{
|
||||
abort_unless($this->portfolioEnabled(), 503);
|
||||
abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
|
||||
abort_unless(DB::table('investment_dossiers')->where('id', $id)->whereNull('deleted_at')->exists(), 404);
|
||||
if ($write) {
|
||||
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
|
||||
}
|
||||
}
|
||||
|
||||
public function latestPortfolio(string $id): ?\stdClass
|
||||
{
|
||||
return DB::table('portal_editorial_editions')->where('dossier_id', $id)->orderByDesc('revision_no')->first();
|
||||
}
|
||||
|
||||
public function workingPortfolio(InvestmentDossier $record): InvestmentDossier
|
||||
{
|
||||
$edition = $this->latestPortfolio($record->id);
|
||||
if (! $edition) {
|
||||
return $record;
|
||||
}
|
||||
$copy = clone $record;
|
||||
$fields = $this->payload($edition)['resource'];
|
||||
$copy->forceFill(Arr::only($fields, ['title', 'source', 'content']));
|
||||
$copy->setAttribute('version_no', max((int) $record->getAttribute('version_no'), (int) $edition->revision_no));
|
||||
|
||||
return $copy;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields
|
||||
* @param list<int> $contributors
|
||||
* @return EditorialPayload
|
||||
*/
|
||||
private function portfolioSnapshot(array $fields, array $contributors): array
|
||||
{
|
||||
if (is_string($fields['content'])) {
|
||||
$fields['content'] = json_decode($fields['content'], true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
$links = [];
|
||||
foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $key => $table) {
|
||||
if (! empty($fields[$key])) {
|
||||
$context = DB::table($table)->where('id', $fields[$key])->whereNull('deleted_at')->first();
|
||||
abort_unless($context !== null, 422, 'Contexto da ficha indisponível.');
|
||||
$links[] = ['target_type' => $table, 'target_key' => $fields[$key], 'context' => Arr::only(get_object_vars($context), ['id', 'name', 'code', 'sector', 'status', 'description', 'primary_territory_id', 'territory_version', 'budget_amount', 'budget_currency', 'start_date', 'end_date'])];
|
||||
}
|
||||
}
|
||||
abort_unless(count($links) === 1, 422, 'Contexto único da ficha necessário.');
|
||||
|
||||
return ['resource' => $fields, 'links' => $links, 'files' => [], 'document_changes' => [], 'contributors' => $contributors, 'policy' => ['independent_required' => true]];
|
||||
}
|
||||
|
||||
/** @param EditorialPayload $payload
|
||||
* @return EditorialPayload
|
||||
*/
|
||||
private function refreshPayload(\stdClass $edition, array $payload): array
|
||||
{
|
||||
return ($edition->dossier_id ?? null) ? $this->portfolioSnapshot($payload['resource'], $payload['contributors'])
|
||||
: $this->snapshot($payload['resource'], $payload['links'], $payload['document_changes'], $payload['contributors'], $payload['policy']);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $changes */
|
||||
public function savePortfolio(User $actor, InvestmentDossier $record, int $expected, array $changes): \stdClass
|
||||
{
|
||||
$this->authorizePortfolio($actor, $record->id, true);
|
||||
|
||||
return DB::transaction(function () use ($actor, $record, $expected, $changes) {
|
||||
$record = InvestmentDossier::whereKey($record->id)->lockForUpdate()->firstOrFail();
|
||||
abort_unless((int) $this->workingPortfolio($record)->getAttribute('version_no') === $expected, 409, 'A edição mudou. Recupere a versão actual antes de guardar.');
|
||||
$previous = $this->latestPortfolio($record->id);
|
||||
$public = $this->portfolioSnapshot($record->getAttributes(), []);
|
||||
if (! $previous && $record->getAttribute('publication_status') === 'published') {
|
||||
DB::table('portal_editorial_editions')->insert(['id' => (string) Str::uuid(), 'dossier_id' => $record->id, 'revision_no' => $expected,
|
||||
'state' => 'published', 'version' => 1, 'payload' => json_encode($public, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($public),
|
||||
'author_id' => $record->editor_id ?? $actor->id, 'legal_required' => false, 'published_at' => $record->getAttribute('published_at'), 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
$base = $previous ? $this->payload($previous) : $public;
|
||||
$fields = array_replace($base['resource'], Arr::only($changes, ['title', 'source', 'content']), ['version_no' => $expected + 1]);
|
||||
$contributors = array_values(array_unique([...($previous && $previous->state !== 'published' ? $base['contributors'] : []), $actor->id]));
|
||||
$snapshot = $this->portfolioSnapshot($fields, $contributors);
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('portal_editorial_editions')->insert(['id' => $id, 'dossier_id' => $record->id, 'revision_no' => $expected + 1,
|
||||
'state' => 'draft', 'version' => 1, 'payload' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($snapshot),
|
||||
'author_id' => $actor->id, 'legal_required' => false, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$edition = DB::table('portal_editorial_editions')->where('id', $id)->first();
|
||||
$this->audit($actor, $edition, 'draft_saved', 'Edição privada da carteira guardada; publicação preservada.');
|
||||
|
||||
return $edition;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param EditorialPayload $payload */
|
||||
private function publishPortfolio(\stdClass $record, \stdClass $edition, array $payload): void
|
||||
{
|
||||
$approval = json_decode($edition->approval, true, 512, JSON_THROW_ON_ERROR);
|
||||
$fields = Arr::only($payload['resource'], ['title', 'source', 'content']);
|
||||
$fields['content'] = json_encode($fields['content'], JSON_THROW_ON_ERROR);
|
||||
DB::table('investment_dossiers')->where('id', $record->id)->update([...$fields, 'version_no' => max((int) $record->version_no + 1, (int) $edition->revision_no),
|
||||
'editor_id' => $edition->author_id, 'reviewer_id' => $approval['actor_id'], 'approval_basis' => 'manager', 'approval_evidence' => $approval['reason'], 'publication_status' => 'published', 'published_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $changes
|
||||
* @param list<array<string,mixed>>|null $links
|
||||
*/
|
||||
@@ -311,16 +454,22 @@ final class PortalEditorialWorkflow
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function transition(User $actor, int $resource, string $id, array $input): \stdClass
|
||||
public function transition(User $actor, int|string $resource, string $id, array $input, bool $portfolio = false): \stdClass
|
||||
{
|
||||
$this->authorize($actor, $resource, true);
|
||||
$data = validator($input, ['version' => 'required|integer|min:1', 'action' => 'required|in:submit,start_review,return,reject,legal_validate,approve,publish,archive', 'reason' => 'required|string|min:3|max:5000', 'assigned_to' => 'nullable|integer|exists:users,id'])->validate();
|
||||
if ($portfolio) {
|
||||
$this->authorizePortfolio($actor, (string) $resource, true);
|
||||
} else {
|
||||
$this->authorize($actor, (int) $resource, true);
|
||||
}
|
||||
$reason = $portfolio && in_array($input['action'] ?? '', ['approve', 'publish'], true) ? 'required|string|min:15|max:3000' : 'required|string|min:3|max:5000';
|
||||
$data = validator($input, ['version' => 'required|integer|min:1', 'action' => 'required|in:submit,start_review,return,reject,legal_validate,approve,publish,archive', 'reason' => $reason, 'assigned_to' => 'nullable|integer|exists:users,id'])->validate();
|
||||
|
||||
return DB::transaction(function () use ($actor, $resource, $id, $data) {
|
||||
$r = DB::table('portal_resources')->where('id', $resource)->lockForUpdate()->first();
|
||||
$e = DB::table('portal_editorial_editions')->where('id', $id)->where('resource_id', $resource)->lockForUpdate()->first();
|
||||
return DB::transaction(function () use ($actor, $resource, $id, $data, $portfolio) {
|
||||
$r = DB::table($portfolio ? 'investment_dossiers' : 'portal_resources')->where('id', $resource)->lockForUpdate()->first();
|
||||
$e = DB::table('portal_editorial_editions')->where('id', $id)->where($portfolio ? 'dossier_id' : 'resource_id', $resource)->lockForUpdate()->first();
|
||||
abort_unless($r && $e, 404);
|
||||
abort_unless((int) $e->version === (int) $data['version'] && $this->latest($resource)?->id === $id, 409, 'Versão desactualizada.');
|
||||
$latest = $portfolio ? $this->latestPortfolio((string) $resource) : $this->latest((int) $resource);
|
||||
abort_unless((int) $e->version === (int) $data['version'] && $latest?->id === $id, 409, 'Versão desactualizada.');
|
||||
$action = $data['action'];
|
||||
if (! in_array($action, ['submit', 'archive'], true)) {
|
||||
abort_unless(app(DocumentManagementAccess::class)->full($actor), 403, 'Responsável de revisão/publicação necessário.');
|
||||
@@ -335,11 +484,11 @@ final class PortalEditorialWorkflow
|
||||
abort_unless(in_array($e->state, $allowed, true), 409, 'Transição incompatível com o estado actual.');
|
||||
$payload = $this->payload($e);
|
||||
// Content is immutable; changed files invalidate the decision rather than being silently accepted.
|
||||
if ($action !== 'archive') {
|
||||
abort_unless(hash_equals($e->content_hash, $this->hash($this->snapshot($payload['resource'], $payload['links'], $payload['document_changes'], $payload['contributors'], $payload['policy']))), 409, 'Conteúdo ou anexos alterados. Guarde uma nova edição e obtenha nova aprovação.');
|
||||
if (in_array($action, ['submit', 'approve', 'legal_validate', 'publish'], true)) {
|
||||
abort_unless(hash_equals($e->content_hash, $this->hash($this->refreshPayload($e, $payload))), 409, 'Conteúdo, contexto ou anexos alterados. Guarde uma nova edição e obtenha nova aprovação.');
|
||||
}
|
||||
$updates = [];
|
||||
if (in_array($action, ['approve', 'legal_validate'], true) && $this->requiresIndependent($e)) {
|
||||
if ((in_array($action, ['approve', 'legal_validate'], true) || ($portfolio && $action === 'publish')) && $this->requiresIndependent($e)) {
|
||||
abort_if(in_array($actor->id, $payload['contributors'], true), 403, 'Esta edição exige revisão independente.');
|
||||
}
|
||||
if (in_array($action, ['approve', 'legal_validate'], true)) {
|
||||
@@ -362,16 +511,25 @@ final class PortalEditorialWorkflow
|
||||
if ($e->legal_required) {
|
||||
$this->assertDecision($e, $e->legal_validation, 'Validação jurídica desta edição necessária.', true);
|
||||
}
|
||||
$this->publish($actor, $r, $e, $payload);
|
||||
if ($portfolio) {
|
||||
$this->publishPortfolio($r, $e, $payload);
|
||||
} else {
|
||||
$this->publish($actor, $r, $e, $payload);
|
||||
}
|
||||
$updates['published_at'] = now();
|
||||
}
|
||||
if ($action === 'submit' && isset($data['assigned_to'])) {
|
||||
$responsible = User::query()->whereKey($data['assigned_to'])->firstOrFail();
|
||||
abort_unless(app(DocumentManagementAccess::class)->full($responsible) && ! app(PortalProfileAccess::class)->has($responsible, 'auditor'), 422, 'Responsável sem autoridade editorial.');
|
||||
abort_if($portfolio && ! $responsible->can('manage-portal'), 422, 'Responsável sem autoridade sobre a carteira.');
|
||||
$updates['assigned_to'] = $responsible->id;
|
||||
}
|
||||
if ($action === 'archive' && $e->state === 'published') {
|
||||
DB::table('portal_resources')->where('id', $resource)->update(['published' => false, 'metadata_version' => (int) $r->metadata_version + 1, 'updated_at' => now()]);
|
||||
if ($portfolio) {
|
||||
DB::table('investment_dossiers')->where('id', $resource)->update(['publication_status' => 'draft', 'version_no' => (int) $r->version_no + 1, 'updated_at' => now()]);
|
||||
} else {
|
||||
DB::table('portal_resources')->where('id', $resource)->update(['published' => false, 'metadata_version' => (int) $r->metadata_version + 1, 'updated_at' => now()]);
|
||||
}
|
||||
}
|
||||
$updates['state'] = match ($action) {
|
||||
'submit' => 'submitted', 'start_review', 'legal_validate' => 'in_review', 'return' => 'returned', 'reject' => 'rejected', 'approve' => 'approved', 'publish' => 'published', 'archive' => 'archived',
|
||||
@@ -396,6 +554,7 @@ final class PortalEditorialWorkflow
|
||||
abort_unless(($d['edition_id'] ?? null) === $e->id && ($d['hash'] ?? null) === $e->content_hash, 422, $message);
|
||||
$actor = User::query()->whereKey($d['actor_id'] ?? 0)->first();
|
||||
abort_unless($actor && $actor->hasVerifiedEmail() && app(DocumentManagementAccess::class)->full($actor) && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403, 'Autoridade da decisão revogada.');
|
||||
abort_if(($e->dossier_id ?? null) && ! $actor->can('manage-portal'), 403, 'Autoridade sobre a carteira revogada.');
|
||||
if ($legal) {
|
||||
abort_unless(in_array($actor->id, config('editorial_workflow.legal_reviewers', []), true), 403, 'Designação jurídica revogada.');
|
||||
}
|
||||
@@ -438,7 +597,7 @@ final class PortalEditorialWorkflow
|
||||
private function audit(User $actor, \stdClass $e, string $action, string $reason, ?string $nextState = null): void
|
||||
{
|
||||
$current = DB::table('portal_editorial_editions')->where('id', $e->id)->first();
|
||||
app(DocumentManagementAccess::class)->audit($actor, 'editorial_'.$action, ['resource_id' => $e->resource_id, 'edition_id' => $e->id, 'hash' => $e->content_hash, 'version' => $e->version,
|
||||
app(DocumentManagementAccess::class)->audit($actor, 'editorial_'.$action, ['resource_id' => $e->resource_id, 'dossier_id' => $e->dossier_id ?? null, 'edition_id' => $e->id, 'hash' => $e->content_hash, 'version' => $e->version,
|
||||
'from_state' => $e->state, 'to_state' => $nextState ?? $e->state, 'result_version' => $current->version, 'approval' => $current->approval, 'legal_validation' => $current->legal_validation, 'reason' => $reason]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ return [
|
||||
// Development/QA opt-in. Installation and activation in an operational target are separate decisions.
|
||||
'enabled' => env('PORTAL_EDITORIAL_WORKFLOW_ENABLED', false),
|
||||
'cluster_competitiveness' => env('PORTAL_EDITORIAL_CLUSTER_WORKFLOW_ENABLED', false),
|
||||
'portfolio' => env('PORTAL_EDITORIAL_PORTFOLIO_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".
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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_editions', function (Blueprint $t): void {
|
||||
$t->unsignedBigInteger('resource_id')->nullable()->change();
|
||||
$t->foreignUuid('dossier_id')->nullable()->constrained('investment_dossiers')->restrictOnDelete();
|
||||
$t->unique(['dossier_id', 'revision_no']);
|
||||
});
|
||||
DB::statement('ALTER TABLE portal_editorial_editions ADD CONSTRAINT portal_editorial_edition_subject CHECK (num_nonnulls(resource_id,dossier_id)=1)');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_editions')->whereNotNull('dossier_id')->exists()) {
|
||||
throw new RuntimeException('Preserve portfolio editorial history.');
|
||||
}
|
||||
DB::statement('ALTER TABLE portal_editorial_editions DROP CONSTRAINT portal_editorial_edition_subject');
|
||||
Schema::table('portal_editorial_editions', function (Blueprint $t): void {
|
||||
$t->dropUnique(['dossier_id', 'revision_no']);
|
||||
$t->dropForeign(['dossier_id']);
|
||||
$t->dropColumn('dossier_id');
|
||||
$t->unsignedBigInteger('resource_id')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,17 @@
|
||||
## 13/11/2026 — fichas da carteira integradas no mesmo circuito e pacote editorial
|
||||
|
||||
**Lacuna corrigida em QA:** guardar uma ficha de projecto âncora, cluster ou corredor deixava de a disponibilizar ao público. Com `PORTAL_EDITORIAL_PORTFOLIO_ENABLED` activo, o editor existente guarda uma edição privada e conserva integralmente o registo público, incluindo título, conteúdo, fonte, versão e datas. A confirmação independente continua obrigatória; aprovar e publicar são operações distintas. A publicação anterior só é substituída pela publicação explícita da edição aprovada. Arquivar uma edição privada preserva-a; arquivar a edição pública actual retira-a da consulta, sem apagar dados ou históricos.
|
||||
|
||||
**Reutilização:** mesmas tabelas, decisões, transições, auditoria, responsáveis, notificações e ecrã do circuito Fase 1. A migração aditiva `2026_11_13_230000_extend_editorial_editions_to_portfolio` permite que uma edição pertença a um recurso documental OU a uma ficha da carteira, com chaves estrangeiras, unicidade da revisão e exclusividade do sujeito. Não foi criado outro motor nem um segundo dossier. A estrutura foi instalada exclusivamente em `editorial_phase1_20261113_test:55441`; recibo em `outputs/editorial-portfolio-20261113/qa-installation.json`, com as contagens das 177 tabelas anteriores preservadas, excepto o incremento do livro de migrações. A reversão recusa remover histórico da carteira existente.
|
||||
|
||||
**Percurso:** Gestão de conteúdos → Carteira → abrir ficha → guardar alteração → «Conferir versões, submeter, aprovar e publicar no circuito editorial». O ecrã mostra a edição exacta, a fonte e o contexto identificado, conteúdo, versões, decisão e histórico. Submeter com responsável, iniciar revisão, aprovar e publicar separadamente. O painel indica as fichas pendentes; as notificações ligam à edição concreta. Os gestores usam as autorizações existentes; todas as escritas exigem MFA válido. API/JSON e prévias verificam a autoridade no servidor. Leitura privada usa `private, no-store`; a gestão da carteira reutiliza também o histórico Inertia cifrado.
|
||||
|
||||
**Integridade:** o hash liga a edição ao seu conteúdo e contexto de projecto/cluster/corredor, incluindo dados relevantes da entidade. Alterações do contexto ou da edição exigem nova aprovação. Todos os colaboradores privados são excluídos da aprovação e publicação dessa edição, preservando a independência já exigida na carteira. A publicação reconfere a autoridade do aprovador. Tentativas pela antiga operação combinada são recusadas. Desactivar o módulo não permite contornar o circuito para editar fichas com histórico novo. Um contexto desactualizado impede aprovar/publicar, mas permite devolver/rejeitar para correcção, sem prender a revisão. Versões anteriores, razões e dados deliberados ficam conservados.
|
||||
|
||||
**Provas:** 5 testes novos/199 asserções; regressão Fase 1 11/223; carteira anterior 8/201; notificações nativas de oportunidades 1/20; frontend 20 testes. PHPStan focado, tipos e compilação aprovados. Provas em `outputs/editorial-portfolio-20261113/`, incluindo `integration-final.log`, `phase1-regression-final.log`, `native-regression.log`, `opportunity-notices-regression.log`, `frontend-final.log`, `analysis-final.log`, `types-final.log` e `build.log`. Os testes de carteira anteriores usam a base nativa própria e descartável; os novos usam transacções revertidas. Não foram submetidos processos reais, alteradas permissões ou publicados conteúdos reais.
|
||||
|
||||
**Entrega e saldo:** este avanço integra a candidata agrupada, preservando os pacotes anteriores. A instalação operacional continua por executar e inclui agora três migrações da Fase 1/carteira; as migrações só autorizam QA e o executor Git não executa SQL no destino. É necessário preparar/autorizar a aplicação nominal do schema e contrato, backup/restauro e instalação conjunta pelo processo existente. A conferência visual permanece pendente, sem invalidar as provas aceites. Este recorte versiona as fichas públicas da carteira; a edição dos dados-base de projectos/clusters, relações, estatística, indicadores demográficos e conteúdos territoriais ainda não fica encerrada, nem as notificações próprias de Competitividade. Nenhuma instalação ou activação em homologação/produção foi efectuada.
|
||||
|
||||
## 13/11/2026 — pacote agrupado do Dashboard Colaborativo e revisão de Competitividade
|
||||
|
||||
**Decisão do proprietário:** agrupar melhorias e funcionalidades relacionadas para uma instalação conjunta quando o conjunto estiver pronto. O pacote desta passagem reúne a Fase 1 e o primeiro recorte editorial da Fase 2: análise de Competitividade dos clusters. Não representa o fecho de toda a Fase 2 nem autorização para publicar conteúdos.
|
||||
|
||||
@@ -11130,3 +11130,4 @@ REL-DOSSIER-20261113,"Agrupar no dossier correspondente os relatórios produzido
|
||||
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
|
||||
ACT-20261113-EDITORIAL-CLUSTERS,"Separar aprovação/publicação da análise de Competitividade; preservar publicação, contexto, versões e autoridade; agrupar entrega relacionada","Directiva de continuidade e decisão de pacote conjunto do proprietário, 13/11/2026; Fase 2 já delimitada",Dashboard Colaborativo / Competitividade dos clusters,/management/clusters; /management/clusters/{id}/competitiveness/{action}; Gestão de conteúdos; Circuito editorial,"Extensão do circuito cifrado existente: submissão, revisão, aprovação e publicação distintas; devolução/rejeição/arquivo; versões/contexto; MFA e autoridade efectiva; navegação reutilizada",QA isolado; flag própria desactivada por defeito; sem instalação operacional,"outputs/editorial-clusters-20261113: integração 14/252; regressão Fase 1 11/223; frontend 18; PHPStan, tipos e build aprovados",Recorte implementado em QA; não encerra a Fase 2,Conferência visual; notificações dos clusters; versionamento dos dados-base e outros conteúdos Fase 2; preparação/instalação operacional,Instalação agrupada exige preparar e autorizar schema/contrato da Fase 1 no destino; não bloqueia QA,Aprovar não publica; rascunho/recuperação preservam publicação; alteração do contexto invalida aprovação; independência quando exigida; revogação e conflitos bloqueados; retirar publicação preserva revisão privada,Alta,,,,,Editor e lista existentes com estados distintos; entradas de gestão integradas,"JSON autenticado, MFA e autorizações verificadas no servidor",Histórico cifrado existente; sem novas tabelas ou migrações deste recorte,Histórico recuperado como nova edição; publicação anterior preservada,Publicação só por confirmação distinta após aprovação da edição exacta,,,,Provas sintéticas do delta e regressão afectada; provas anteriores reutilizadas,Conferência visual; notificações clusters e restantes conteúdos Fase 2; instalação conjunta,"ClusterCompetitiveness, audit_events cifrados, gestão de estruturas, PortalProfileAccess, ContentManagement e circuito Fase 1",,Agrupar funcionalidades relacionadas num pacote e instalar em conjunto quando prontas; sem intervenção operacional nesta passagem,Implementação localizada sem migração; pacote Git agrupado,,outputs/editorial-clusters-20261113; manual unificado actualizado,Dashboard Colaborativo — pacote conjunto,13/11/2026,,,,,,,,Regressão anterior de Competitividade e Fase 1; oportunidades mantêm provas aceites,"Ciclo editorial de Competitividade, preservação pública, permissões, independência, recuperação e contexto","Sem conferência visual ou instalação operacional; não prova versão editorial dos dados-base, notificações clusters nem restantes conteúdos Fase 2",,Circuito editorial e agrupamento de entregas,Submeter → rever → aprovar → publicar; preservar a publicação e autoridade efectiva,QA demonstrado; entrega agrupada preparada; instalação pendente
|
||||
ACT-20261113-EDITORIAL-CARTEIRA,Preservar fichas públicas de projecto âncora/cluster/corredor enquanto novas versões seguem o circuito editorial; integrar pacote conjunto,"Directiva de continuidade e decisão de agrupar funcionalidades relacionadas, 13/11/2026",Dashboard Colaborativo / carteira de investimento,/management/portfolio/{id}; /management/editorial/portfolio/{id}; transições/notificações existentes,Edições privadas na mesma tabela da Fase 1; aprovação/publicação separadas; fonte/contexto por hash; independência de todos os colaboradores; MFA e auditoria; pendências e notificações,Estrutura e provas apenas em QA 55441; flag própria desactivada por defeito; sem instalação operacional,outputs/editorial-portfolio-20261113: novos 5/199; Fase 1 11/223; carteira anterior 8/201; notificações oportunidades 1/20; frontend 20; PHPStan/tipos/build aprovados,Fichas da carteira implementadas em QA; dados-base e restante Fase 2 pendentes,Conferência visual; preparação/instalação operacional; versionamento de dados-base e restantes conteúdos Fase 2,Schema/contrato operacional da entrega agrupada exige preparação e autorização concreta; não bloqueia QA,"Guardar/aprovar mantém todos os campos públicos; publicar aplica só edição aprovada; independência, revogação, MFA e versões verificadas no servidor; contexto alterado exige nova decisão; histórico e notificações recuperáveis",Alta,,,,,"Editor existente, ecrã de revisão reutilizado, versões/contexto, acesso pelo painel e Gestão de conteúdos",JSON/HTML protegidos pelas mesmas autorizações; leitura privada sem cache,Edições e decisões na tabela comum; migração QA aditiva com exclusividade do sujeito e rollback protegido,Rascunho e versões anteriores recuperados sem substituir publicação; edições deliberadas preservadas,Ficha pública mantida; nova publicação só por operação independente explícita,,,,Cinco percursos de integração novos e regressões afectadas aprovados; não equivale ao fecho do módulo de dados-base,"Dados-base, relações, estatística/demografia/território; conferência visual e instalação conjunta","PortalEditorialWorkflow, portal_editorial_editions, investment_dossiers, auditoria, perfis/MFA, editor e prévias nativos",,Implementar em QA e agrupar com Fase 1/Competitividade; sem intervenção operacional,Integração aditiva no circuito existente; sem segundo dossier ou motor,,Recibo e provas em outputs/editorial-portfolio-20261113; manual unificado actualizado,Dashboard Colaborativo — pacote conjunto,13/11/2026,,,,,,,,"Regressão Fase 1, carteira anterior e notificações nativas; Competitividade mantém provas anteriores aplicáveis","Preservação pública, versões/contexto, independência, MFA, autoridade, histórico/notificações e bloqueio de bypass",Sem revisão visual ou instalação operacional; não prova versionamento dos dados-base nem restantes conteúdos Fase 2,,Carteira e continuidade do circuito editorial,Ficha existente → edição privada → submissão → revisão → aprovação → publicação; consulta pública e recuperação preservadas,QA demonstrado; integrado na candidata agrupada; instalação pendente
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -3,6 +3,7 @@ import { Frame, card, button } from '@/pages/investment/shared';
|
||||
import { states } from './shared';
|
||||
|
||||
type Access = {
|
||||
portfolio?: { index: string };
|
||||
clusters?: { index: string };
|
||||
editorial?: { index: string };
|
||||
procurement?: { index: string; create: string };
|
||||
@@ -28,6 +29,20 @@ export default function ContentHub({
|
||||
acção respeita a sua autorização actual.
|
||||
</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{access.portfolio && (
|
||||
<section className={card}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Carteira de investimento
|
||||
</h2>
|
||||
<p>
|
||||
Rever versões das fichas de projectos, clusters e
|
||||
corredores, preservando a publicação anterior.
|
||||
</p>
|
||||
<Link className={button} href={access.portfolio.index}>
|
||||
Gerir fichas e revisões da carteira
|
||||
</Link>
|
||||
</section>
|
||||
)}
|
||||
{access.clusters && (
|
||||
<section className={card}>
|
||||
<h2 className="text-xl font-semibold">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { Management, Field, button, control } from './shared';
|
||||
import { states } from './editorial-workflow';
|
||||
import { labels as portfolioLabels } from '../portfolio/shared';
|
||||
|
||||
type Edition = {
|
||||
id: string;
|
||||
@@ -15,6 +16,8 @@ type Edition = {
|
||||
title: string;
|
||||
description?: string;
|
||||
editorial_content?: string | null;
|
||||
source?: string;
|
||||
content?: Record<string, string | null>;
|
||||
};
|
||||
files: {
|
||||
id: string;
|
||||
@@ -22,6 +25,11 @@ type Edition = {
|
||||
version_no: number;
|
||||
sha256: string;
|
||||
}[];
|
||||
links?: {
|
||||
target_type: string;
|
||||
target_key: string;
|
||||
context?: { name?: string; code?: string; sector?: string };
|
||||
}[];
|
||||
};
|
||||
approval: string | null;
|
||||
legal_validation: string | null;
|
||||
@@ -37,9 +45,11 @@ export default function EditorialEdition({
|
||||
current,
|
||||
canApprove,
|
||||
independentRequired,
|
||||
subject = 'resource',
|
||||
}: {
|
||||
subject?: 'resource' | 'portfolio';
|
||||
resource: {
|
||||
id: number;
|
||||
id: number | string;
|
||||
title: string;
|
||||
published: boolean;
|
||||
public_edition_no?: number | null;
|
||||
@@ -60,6 +70,7 @@ export default function EditorialEdition({
|
||||
canApprove: boolean;
|
||||
independentRequired: boolean;
|
||||
}) {
|
||||
const editionBase = `/management/editorial/${subject === 'portfolio' ? 'portfolio' : 'resources'}/${resource.id}`;
|
||||
const form = useForm({
|
||||
version: edition?.version ?? 1,
|
||||
action: '',
|
||||
@@ -92,15 +103,31 @@ export default function EditorialEdition({
|
||||
<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>
|
||||
{subject === 'portfolio' ? (
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/management/portfolio/${resource.id}`}
|
||||
>
|
||||
Alterar ficha da carteira
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/management/portal-services/resources/${resource.id}/article`}
|
||||
>
|
||||
Alterar artigo
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{subject === 'resource' && (
|
||||
<Link
|
||||
className="underline"
|
||||
href="/management/portal-services"
|
||||
>
|
||||
Alterar metadados/documento
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<p>
|
||||
{resource.published
|
||||
@@ -126,13 +153,44 @@ export default function EditorialEdition({
|
||||
<p className="text-sm break-all">
|
||||
Identificador da edição: {edition.id}
|
||||
<br />
|
||||
Hash do conteúdo e anexos: {edition.content_hash}
|
||||
Hash do conteúdo e{' '}
|
||||
{subject === 'portfolio' ? 'contexto' : '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>
|
||||
{subject === 'portfolio' && (
|
||||
<>
|
||||
<p>Fonte: {edition.payload.resource.source}</p>
|
||||
<dl>
|
||||
{Object.entries(
|
||||
edition.payload.resource.content ?? {},
|
||||
).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<dt className="font-semibold">
|
||||
{portfolioLabels[key] ?? key}
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
{subject === 'portfolio' &&
|
||||
edition.payload.links?.map((link) => (
|
||||
<p key={link.target_key}>
|
||||
Contexto: {link.context?.name} ·{' '}
|
||||
{link.context?.code}
|
||||
{link.context?.sector
|
||||
? ` · ${link.context.sector}`
|
||||
: ''}
|
||||
</p>
|
||||
))}
|
||||
{edition.payload.resource.editorial_content && (
|
||||
<p className="whitespace-pre-wrap">
|
||||
{
|
||||
@@ -193,7 +251,7 @@ export default function EditorialEdition({
|
||||
assigned_to: data.assigned_to || null,
|
||||
}));
|
||||
form.post(
|
||||
`/management/editorial/resources/${resource.id}/editions/${edition.id}`,
|
||||
`${editionBase}/editions/${edition.id}`,
|
||||
{
|
||||
onSuccess: () =>
|
||||
form.reset('action', 'reason'),
|
||||
@@ -225,8 +283,16 @@ export default function EditorialEdition({
|
||||
canApprove)) &&
|
||||
(action !== 'approve' ||
|
||||
canApprove) &&
|
||||
(subject !== 'portfolio' ||
|
||||
action !== 'publish' ||
|
||||
canApprove) &&
|
||||
(edition.fresh ||
|
||||
action === 'archive'),
|
||||
[
|
||||
'archive',
|
||||
'return',
|
||||
'reject',
|
||||
'start_review',
|
||||
].includes(action)),
|
||||
)
|
||||
.map((action) => (
|
||||
<option key={action} value={action}>
|
||||
@@ -286,10 +352,7 @@ export default function EditorialEdition({
|
||||
) : (
|
||||
<p>
|
||||
Versão histórica para consulta.{' '}
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/management/editorial/resources/${resource.id}`}
|
||||
>
|
||||
<Link className="underline" href={editionBase}>
|
||||
Voltar à edição actual
|
||||
</Link>
|
||||
</p>
|
||||
@@ -307,7 +370,7 @@ export default function EditorialEdition({
|
||||
<li key={e.id}>
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/management/editorial/resources/${resource.id}?edition=${e.id}`}
|
||||
href={`${editionBase}?edition=${e.id}`}
|
||||
>
|
||||
Edição {e.revision_no}: {states[e.state] ?? e.state}
|
||||
</Link>
|
||||
|
||||
@@ -38,11 +38,15 @@ export default function EditorialWorkflow({
|
||||
opportunitiesUrl,
|
||||
opportunityPending,
|
||||
clustersUrl,
|
||||
portfolioUrl,
|
||||
portfolioPending = 0,
|
||||
}: {
|
||||
resourceAccess: boolean;
|
||||
opportunitiesUrl: string | null;
|
||||
opportunityPending: number;
|
||||
clustersUrl?: string | null;
|
||||
portfolioUrl?: string | null;
|
||||
portfolioPending?: number;
|
||||
filters?: { state?: string; kind?: string };
|
||||
items: { data: Item[]; current_page: number; last_page: number };
|
||||
notices: {
|
||||
@@ -63,6 +67,11 @@ export default function EditorialWorkflow({
|
||||
próprio.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{portfolioUrl && (
|
||||
<Link className="underline" href={portfolioUrl}>
|
||||
Carteira: {portfolioPending} fichas pendentes
|
||||
</Link>
|
||||
)}
|
||||
{clustersUrl && (
|
||||
<Link className="underline" href={clustersUrl}>
|
||||
Competitividade dos clusters
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function Show(props: Props) {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
editorialEnabled?: boolean;
|
||||
competitiveness?: ApprovedCompetitiveness | null;
|
||||
complements?: ComplementOption[];
|
||||
geography: StructureGeography;
|
||||
@@ -63,6 +64,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function Editor({
|
||||
editorialEnabled = false,
|
||||
competitiveness,
|
||||
complements = [],
|
||||
record,
|
||||
@@ -423,43 +425,57 @@ function Editor({
|
||||
Guardar rascunho
|
||||
</button>
|
||||
</form>
|
||||
<form
|
||||
className="space-y-3 rounded border p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
approval.post(`${base}/${record.id}/publish`);
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
O gestor autorizado pode aprovar e publicar esta
|
||||
ficha. Registe a referência da sua decisão.
|
||||
{editorialEnabled ? (
|
||||
<p className="rounded border p-4">
|
||||
A edição privada preserva a ficha pública anterior.{' '}
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/management/editorial/portfolio/${record.id}`}
|
||||
>
|
||||
Conferir versões, submeter, aprovar e publicar
|
||||
no circuito editorial
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<Field label="Referência da autorização e revisão">
|
||||
<textarea
|
||||
className={control}
|
||||
required
|
||||
minLength={15}
|
||||
value={approval.data.approval_evidence}
|
||||
onChange={(e) =>
|
||||
approval.setData(
|
||||
'approval_evidence',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{approval.errors.approval_evidence && (
|
||||
<p role="alert">
|
||||
{approval.errors.approval_evidence}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
className={button}
|
||||
disabled={approval.processing || form.isDirty}
|
||||
) : (
|
||||
<form
|
||||
className="space-y-3 rounded border p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
approval.post(`${base}/${record.id}/publish`);
|
||||
}}
|
||||
>
|
||||
Validar e publicar
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
O gestor autorizado pode aprovar e publicar esta
|
||||
ficha. Registe a referência da sua decisão.
|
||||
</p>
|
||||
<Field label="Referência da autorização e revisão">
|
||||
<textarea
|
||||
className={control}
|
||||
required
|
||||
minLength={15}
|
||||
value={approval.data.approval_evidence}
|
||||
onChange={(e) =>
|
||||
approval.setData(
|
||||
'approval_evidence',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
{approval.errors.approval_evidence && (
|
||||
<p role="alert">
|
||||
{approval.errors.approval_evidence}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
className={button}
|
||||
disabled={approval.processing || form.isDirty}
|
||||
>
|
||||
Validar e publicar
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
+4
-2
@@ -101,6 +101,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
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('/management/editorial/portfolio/{id}', [PortalEditorialWorkflowController::class, 'portfolio'])->whereUuid('id');
|
||||
Route::post('/management/editorial/portfolio/{id}/editions/{edition}', [PortalEditorialWorkflowController::class, 'portfolioTransition'])->whereUuid(['id', 'edition']);
|
||||
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');
|
||||
@@ -305,13 +307,13 @@ Route::middleware(['auth', 'verified', EnsureScopedPortalAdministration::class])
|
||||
Route::get('demands/{id}/analysis', [EducationController::class, 'analysis'])->whereUuid('id')->name('analysis');
|
||||
Route::post('demands/{id}/investment', [EducationInvestmentController::class, 'store'])->whereUuid('id')->name('investment');
|
||||
});
|
||||
Route::get('portfolio', [InvestmentPortfolioController::class, 'index'])->name('portfolio.index');
|
||||
Route::get('portfolio', [InvestmentPortfolioController::class, 'index'])->middleware(PrivateInvestmentResponse::class)->name('portfolio.index');
|
||||
Route::get('relationship-repair', [InvestmentRelationshipRepairController::class, 'preview'])->name('relationship-repair.preview');
|
||||
Route::get('opportunity-origin', [OpportunityOriginReviewController::class, 'index'])->name('opportunity-origin.index');
|
||||
Route::post('opportunity-origin/preview', [OpportunityOriginReviewController::class, 'preview'])->name('opportunity-origin.preview');
|
||||
Route::post('opportunity-origin/apply', [OpportunityOriginReviewController::class, 'apply'])->name('opportunity-origin.apply');
|
||||
Route::post('relationship-repair', [InvestmentRelationshipRepairController::class, 'apply'])->name('relationship-repair.apply');
|
||||
Route::get('portfolio/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->name('portfolio.show');
|
||||
Route::get('portfolio/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->middleware(PrivateInvestmentResponse::class)->name('portfolio.show');
|
||||
Route::put('portfolio/{dossier}', [InvestmentPortfolioController::class, 'save'])->name('portfolio.save');
|
||||
Route::post('portfolio/{dossier}/publish', [InvestmentPortfolioController::class, 'publish'])->name('portfolio.publish');
|
||||
Route::get('clusters/{cluster}/competitiveness', [ClusterCompetitivenessController::class, 'show'])->name('clusters.competitiveness.show');
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\DevelopmentCorridor;
|
||||
use App\Models\EconomicCluster;
|
||||
use App\Models\InvestmentDossier;
|
||||
use App\Models\Project;
|
||||
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 Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\Support\AuthenticatesQaActors;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class PortfolioEditorialWorkflowTest extends TestCase
|
||||
{
|
||||
use AuthenticatesQaActors, DatabaseTransactions;
|
||||
|
||||
private User $author;
|
||||
|
||||
private User $reviewer;
|
||||
|
||||
private InvestmentDossier $dossier;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
|
||||
if (! Schema::hasColumn('portal_editorial_editions', 'dossier_id')) {
|
||||
(require database_path('migrations/2026_11_13_230000_extend_editorial_editions_to_portfolio.php'))->up();
|
||||
}
|
||||
$this->withoutVite();
|
||||
[$this->author, $this->reviewer] = User::factory()->count(2)->create()->all();
|
||||
config(['portal.managers' => [$this->author->email, $this->reviewer->email], 'portal.profile_access_enabled' => false,
|
||||
'editorial_workflow.enabled' => true, 'editorial_workflow.portfolio' => true]);
|
||||
$this->makeDossier('project');
|
||||
}
|
||||
|
||||
private function makeDossier(string $kind): void
|
||||
{
|
||||
$model = match ($kind) {
|
||||
'project' => Project::class, 'cluster' => EconomicCluster::class, 'corridor' => DevelopmentCorridor::class,
|
||||
};
|
||||
$entity = $model::create(['code' => 'ED-QA-'.bin2hex(random_bytes(6)), 'name' => 'Contexto sintético', 'status' => 'active', ...($kind === 'cluster' ? ['sector' => 'Energia'] : [])]);
|
||||
$this->dossier = InvestmentDossier::create([$kind.'_id' => $entity->id, 'title' => 'Ficha pública sintética '.$kind, 'source' => 'Fonte sintética QA', 'content' => ['description' => 'Texto público original'],
|
||||
'version_no' => 1, 'publication_status' => 'published', 'approval_basis' => 'document_owner', 'approval_evidence' => 'Publicação sintética aceite em QA', 'published_at' => now()]);
|
||||
}
|
||||
|
||||
private function edit(string $text = 'Alteração privada deliberada', int $status = 302, ?int $expected = null): ?\stdClass
|
||||
{
|
||||
$this->actingAsWithMfa($this->author);
|
||||
$record = app(PortalEditorialWorkflow::class)->workingPortfolio($this->dossier->fresh());
|
||||
$this->put('/management/portfolio/'.$record->id, ['version_no' => $expected ?? $record->version_no, 'title' => 'Novo título privado', 'source' => 'Fonte sintética revista', 'content' => ['description' => $text]])->assertStatus($status);
|
||||
|
||||
return app(PortalEditorialWorkflow::class)->latestPortfolio($record->id);
|
||||
}
|
||||
|
||||
private function action(User $actor, \stdClass $edition, string $action, int $status = 200, array $extra = []): ?\stdClass
|
||||
{
|
||||
$this->actingAsWithMfa($actor);
|
||||
$this->postJson('/management/editorial/portfolio/'.$this->dossier->id.'/editions/'.$edition->id,
|
||||
['version' => $edition->version, 'action' => $action, 'reason' => 'Decisão fundamentada de demonstração em QA', ...$extra])->assertStatus($status);
|
||||
|
||||
return DB::table('portal_editorial_editions')->where('id', $edition->id)->first();
|
||||
}
|
||||
|
||||
private function approve(\stdClass $edition): \stdClass
|
||||
{
|
||||
$edition = $this->action($this->author, $edition, 'submit', 200, ['assigned_to' => $this->reviewer->id]);
|
||||
$edition = $this->action($this->reviewer, $edition, 'start_review');
|
||||
|
||||
return $this->action($this->reviewer, $edition, 'approve');
|
||||
}
|
||||
|
||||
public function test_public_project_cluster_and_corridor_fiches_survive_private_edit_review_and_approval(): void
|
||||
{
|
||||
foreach (['project', 'cluster', 'corridor'] as $kind) {
|
||||
if ($kind !== 'project') {
|
||||
$this->makeDossier($kind);
|
||||
}
|
||||
$before = $this->dossier->fresh()->getAttributes();
|
||||
$edition = $this->edit();
|
||||
$this->assertSame($before, $this->dossier->fresh()->getAttributes());
|
||||
$this->get('/carteira/'.$this->dossier->id)->assertOk()->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Texto público original'));
|
||||
$this->get('/management/portfolio/'.$this->dossier->id)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Alteração privada deliberada')->where('editorialEnabled', true));
|
||||
$this->edit('Edição obsoleta', 409, 1);
|
||||
$edition = $this->approve($edition);
|
||||
$this->assertSame($before, $this->dossier->fresh()->getAttributes());
|
||||
$this->action($this->author, $edition, 'publish', 403);
|
||||
$this->action($this->reviewer, $edition, 'publish');
|
||||
$this->get('/carteira/'.$this->dossier->id)->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Alteração privada deliberada'));
|
||||
$this->assertSame($this->dossier->id, $this->dossier->fresh()->id);
|
||||
$this->assertSame($this->reviewer->id, $this->dossier->fresh()->reviewer_id);
|
||||
if ($kind === 'corridor') {
|
||||
$published = app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id);
|
||||
$this->action($this->reviewer, $published, 'archive');
|
||||
$this->get('/carteira/'.$this->dossier->id)->assertNotFound();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_changed_context_and_edits_reset_decisions_and_old_publication_remains(): void
|
||||
{
|
||||
$edition = $this->approve($this->edit());
|
||||
Project::whereKey($this->dossier->project_id)->update(['name' => 'Contexto revisto']);
|
||||
$this->getJson('/management/editorial/portfolio/'.$this->dossier->id)->assertJsonPath('edition.fresh', false);
|
||||
$this->action($this->reviewer, $edition, 'publish', 409);
|
||||
$new = $this->edit('Nova edição após alteração do contexto');
|
||||
$this->assertNull($new->approval);
|
||||
$this->assertNull($new->legal_validation);
|
||||
$this->action($this->reviewer, $edition, 'publish', 409);
|
||||
$this->get('/carteira/'.$this->dossier->id)->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Texto público original'));
|
||||
$this->getJson('/management/editorial/portfolio/'.$this->dossier->id.'?edition='.$edition->id)->assertJsonPath('current', false)->assertJsonPath('edition.id', $edition->id);
|
||||
}
|
||||
|
||||
public function test_permissions_api_notice_scope_and_legacy_bypass_fail_closed(): void
|
||||
{
|
||||
$edition = $this->edit();
|
||||
$this->post('/management/portfolio/'.$this->dossier->id.'/publish', ['version_no' => 1, 'approval_evidence' => 'Tentativa legada sintética'])->assertConflict();
|
||||
$edition = $this->action($this->author, $edition, 'submit', 200, ['assigned_to' => $this->reviewer->id]);
|
||||
$this->actingAsWithMfa($this->reviewer);
|
||||
$this->getJson('/management/editorial')->assertOk()->assertJsonPath('portfolioUrl', '/management/portfolio')->assertJsonPath('portfolioPending', 1)
|
||||
->assertJsonPath('notices.0.href', '/management/editorial/portfolio/'.$this->dossier->id.'?edition='.$edition->id);
|
||||
$this->get('/gestao-conteudos')->assertInertia(fn (Assert $p) => $p->where('access.portfolio.index', '/management/portfolio'));
|
||||
$other = User::factory()->create();
|
||||
$this->actingAs($other)->getJson('/management/editorial/portfolio/'.$this->dossier->id)->assertForbidden();
|
||||
$this->action($other, $edition, 'start_review', 403);
|
||||
$this->actingAsWithMfa($this->reviewer);
|
||||
session()->forget('portal.mfa_at');
|
||||
$this->postJson('/management/editorial/portfolio/'.$this->dossier->id.'/editions/'.$edition->id, ['version' => $edition->version, 'action' => 'start_review', 'reason' => 'Motivo sintético QA'])->assertForbidden();
|
||||
config(['editorial_workflow.portfolio' => false]);
|
||||
$this->actingAsWithMfa($this->author);
|
||||
$this->put('/management/portfolio/'.$this->dossier->id, ['version_no' => 1, 'title' => 'Não escrever', 'source' => 'Fonte QA', 'content' => ['description' => 'Não escrever']])->assertStatus(503);
|
||||
$this->get('/carteira/'.$this->dossier->id)->assertOk();
|
||||
$this->actingAsWithMfa($this->reviewer);
|
||||
$this->getJson('/management/editorial')->assertJsonCount(0, 'notices');
|
||||
}
|
||||
|
||||
public function test_revoked_reviewer_and_foreign_edition_cannot_publish_and_private_archive_preserves_public(): void
|
||||
{
|
||||
$firstId = $this->dossier->id;
|
||||
$edition = $this->approve($this->edit());
|
||||
$this->makeDossier('cluster');
|
||||
$this->action($this->reviewer, $edition, 'publish', 404);
|
||||
$this->getJson('/management/editorial/portfolio/'.$this->dossier->id.'?edition='.$edition->id)->assertNotFound();
|
||||
$this->dossier = InvestmentDossier::findOrFail($firstId);
|
||||
config(['portal.managers' => [$this->author->email]]);
|
||||
// The author cannot publish, and a different authorised publisher must recheck the old reviewer.
|
||||
$publisher = User::factory()->create();
|
||||
config(['portal.managers' => [$this->author->email, $publisher->email]]);
|
||||
$this->action($publisher, $edition, 'publish', 403);
|
||||
$this->action($this->author, $edition, 'archive');
|
||||
$this->get('/carteira/'.$this->dossier->id)->assertOk();
|
||||
$this->assertSame('Texto público original', $this->dossier->fresh()->content['description']);
|
||||
}
|
||||
|
||||
public function test_every_private_contributor_is_excluded_and_portfolio_history_cannot_be_removed(): void
|
||||
{
|
||||
$this->edit();
|
||||
$this->actingAsWithMfa($this->reviewer);
|
||||
$this->put('/management/portfolio/'.$this->dossier->id, ['version_no' => 2, 'title' => 'Colaboração privada', 'source' => 'Fonte sintética', 'content' => ['description' => 'Revisão deliberada pelo colaborador']])->assertRedirect();
|
||||
$edition = app(PortalEditorialWorkflow::class)->latestPortfolio($this->dossier->id);
|
||||
$edition = $this->action($this->author, $edition, 'submit');
|
||||
$edition = $this->action($this->reviewer, $edition, 'start_review');
|
||||
$this->action($this->reviewer, $edition, 'approve', 403);
|
||||
$this->action($this->author, $edition, 'approve', 403);
|
||||
Project::whereKey($this->dossier->project_id)->update(['name' => 'Contexto alterado; é necessária correcção']);
|
||||
$this->action($this->reviewer, $edition, 'return');
|
||||
$this->get('/management/portfolio/'.$this->dossier->id)->assertInertia(fn (Assert $p) => $p->where('record.content.description', 'Revisão deliberada pelo colaborador'));
|
||||
try {
|
||||
(require database_path('migrations/2026_11_13_230000_extend_editorial_editions_to_portfolio.php'))->down();
|
||||
$this->fail('Histórico não vazio não pode ser eliminado.');
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->assertStringContainsString('Preserve portfolio', $exception->getMessage());
|
||||
}
|
||||
$this->assertTrue(Schema::hasColumn('portal_editorial_editions', 'dossier_id'));
|
||||
$this->assertSame(3, DB::table('portal_editorial_editions')->where('dossier_id', $this->dossier->id)->count());
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ const code = ts.transpileModule(source, {
|
||||
const module = { exports: {} };
|
||||
new Function('require', 'module', 'exports', code)(
|
||||
(name) => {
|
||||
if (name === '../portfolio/shared')
|
||||
return { labels: { description: 'Visão e proposta de valor' } };
|
||||
if (name === '@inertiajs/react')
|
||||
return {
|
||||
Link: ({ href, children, ...rest }) =>
|
||||
@@ -130,6 +132,11 @@ test('approved edition offers explicit publication; stale files and historical v
|
||||
});
|
||||
assert.match(stale, /Os anexos mudaram/);
|
||||
assert.doesNotMatch(stale, /value="publish"/);
|
||||
const staleReview = render({
|
||||
edition: { ...props.edition, state: 'in_review', fresh: false },
|
||||
});
|
||||
assert.match(staleReview, /value="return"/);
|
||||
assert.doesNotMatch(staleReview, /value="approve"/);
|
||||
const historical = render({ current: false });
|
||||
assert.match(historical, /Versão histórica para consulta/);
|
||||
assert.doesNotMatch(historical, /<form/);
|
||||
@@ -143,3 +150,57 @@ test('contributor does not see independent approval or legal-validation actions'
|
||||
assert.doesNotMatch(html, /value="(?:approve|legal_validate)"/);
|
||||
assert.match(html, /responsável independente/);
|
||||
});
|
||||
|
||||
test('portfolio edition reuses review screen with its own context, history and edit routes', () => {
|
||||
const html = render({
|
||||
subject: 'portfolio',
|
||||
resource: { ...props.resource, id: 'qa-dossier' },
|
||||
edition: {
|
||||
...props.edition,
|
||||
payload: {
|
||||
...props.edition.payload,
|
||||
resource: {
|
||||
title: 'Ficha privada',
|
||||
source: 'Fonte QA',
|
||||
content: { description: '<script>não executar</script>' },
|
||||
},
|
||||
files: [],
|
||||
links: [
|
||||
{
|
||||
target_type: 'projects',
|
||||
target_key: 'qa-project',
|
||||
context: { name: 'Projecto QA', code: 'QA-1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.match(html, /href="\/management\/portfolio\/qa-dossier"/);
|
||||
assert.match(
|
||||
html,
|
||||
/\/management\/editorial\/portfolio\/qa-dossier\?edition=qa-edition/,
|
||||
);
|
||||
assert.match(html, /Visão e proposta de valor/);
|
||||
assert.match(html, /Contexto: Projecto QA/);
|
||||
assert.doesNotMatch(html, /Alterar artigo|Alterar metadados\/documento/);
|
||||
assert.match(html, /<script>/);
|
||||
});
|
||||
|
||||
test('portfolio contributor cannot select publication of an approved edition', () => {
|
||||
assert.doesNotMatch(
|
||||
render({
|
||||
subject: 'portfolio',
|
||||
canApprove: false,
|
||||
edition: { ...props.edition, state: 'approved' },
|
||||
}),
|
||||
/value="publish"/,
|
||||
);
|
||||
assert.match(
|
||||
render({
|
||||
subject: 'portfolio',
|
||||
canApprove: true,
|
||||
edition: { ...props.edition, state: 'approved' },
|
||||
}),
|
||||
/value="publish"/,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user