feat(editorial): preserve public portfolio fiches during private review

This commit is contained in:
2026-11-13 23:23:16 +02:00
parent 1218734eca
commit d2c72f5e13
15 changed files with 678 additions and 74 deletions
@@ -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);