Complete grouped collaborative editorial queues and native review coverage
tests / ci (push) Waiting to run

This commit is contained in:
2026-11-14 11:15:09 +02:00
parent f40924f1be
commit a4541298c0
36 changed files with 2691 additions and 89 deletions
@@ -7,6 +7,8 @@ use App\Models\InvestmentGap;
use App\Models\ValueChain;
use App\Models\ValueChainLink;
use App\Services\PortalAudit;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -21,6 +23,17 @@ class ClusterValueChainController extends Controller
'to_value_chain_id' => ['required', 'uuid', 'different:from_value_chain_id', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')],
'link_type' => ['required', Rule::in(['supplies', 'processes', 'distributes', 'supports'])],
]);
if (app(PortalEditorialSubjects::class)->available()) {
$edition = DB::transaction(function () use ($cluster, $data, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$link = ValueChainLink::where($data)->first();
abort_if($link !== null, 409, 'Ligação existente. Abra a edição desse registo para alterar.');
return app(PortalEditorialSubjects::class)->stageModel($request->user(), 'value-chain-links', new ValueChainLink, $data, 0);
});
return redirect('/management/editorial/subjects/value-chain-links/'.$edition->subject_key)->with('success', 'Ligação guardada numa proposta privada.');
}
DB::transaction(function () use ($cluster, $data, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$link = ValueChainLink::where($data)->first() ?? new ValueChainLink;
@@ -32,6 +45,13 @@ class ClusterValueChainController extends Controller
public function removeLink(Request $request, EconomicCluster $cluster, ValueChainLink $link): RedirectResponse
{
if (app(PortalEditorialSubjects::class)->writes('value-chain-links', $link->id)) {
abort_unless(ValueChain::where('cluster_id', $cluster->id)->whereIn('id', [$link->from_value_chain_id, $link->to_value_chain_id])->count() === 2, 404);
$workflow = app(PortalEditorialWorkflow::class);
$workflow->saveSubject($request->user(), 'value-chain-links', $link->id, $workflow->subjectVersion('value-chain-links', $link->id), [], false, true);
return redirect('/management/editorial/subjects/value-chain-links/'.$link->id)->with('success', 'Retirada proposta. A ligação actual mantém-se até à publicação da decisão.');
}
DB::transaction(function () use ($cluster, $link, $request) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
abort_unless(ValueChain::where('cluster_id', $cluster->id)->whereIn('id', [$link->from_value_chain_id, $link->to_value_chain_id])->count() === 2, 404);
@@ -44,6 +64,7 @@ class ClusterValueChainController extends Controller
public function gap(Request $request, EconomicCluster $cluster, ?InvestmentGap $gap = null): RedirectResponse
{
abort_if($gap && $gap->cluster_id !== $cluster->id, 404);
$editorial = $gap ? app(PortalEditorialSubjects::class)->writes('investment-gaps', $gap->id) : app(PortalEditorialSubjects::class)->available();
$data = $request->validate([
'value_chain_id' => ['required', 'uuid', Rule::exists('value_chains', 'id')->where('cluster_id', $cluster->id)->whereNull('deleted_at')],
'title' => ['required', 'string', 'max:200'],
@@ -52,7 +73,13 @@ class ClusterValueChainController extends Controller
'rationale' => ['required', 'string', 'min:10', 'max:5000'],
'status' => ['required', Rule::in(['identified', 'in_review', 'resolved', 'withdrawn'])],
'call_to_action' => ['required', 'string', 'max:2000'],
...($editorial && $gap ? ['editorial_version' => ['required', 'integer', 'min:1']] : []),
]);
if ($editorial) {
$edition = app(PortalEditorialSubjects::class)->stageModel($request->user(), 'investment-gaps', $gap ?? new InvestmentGap, [...array_diff_key($data, ['editorial_version' => true]), 'cluster_id' => $cluster->id], $gap ? (int) $data['editorial_version'] : 0);
return redirect('/management/editorial/subjects/investment-gaps/'.$edition->subject_key)->with('success', 'Lacuna guardada numa proposta privada.');
}
PortalAudit::save($gap ?? new InvestmentGap, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
return back()->with('success', 'Lacuna de investimento guardada.');
@@ -11,6 +11,7 @@ use App\Models\ValueChain;
use App\Services\ClusterCompetitiveness;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -52,12 +53,15 @@ class InvestmentStructureController extends Controller
$editorialDossier = $id ? app(PortalEditorialWorkflow::class)->structureDossier($structure, $id) : null;
if ($record && $editorialDossier) {
$record = app(PortalEditorialWorkflow::class)->workingStructure($record, $editorialDossier);
} elseif ($record && app(PortalEditorialSubjects::class)->available()) {
$record = clone $record;
$record->forceFill(get_object_vars(app(PortalEditorialSubjects::class)->working($structure, (object) $record->getAttributes())));
}
return Inertia::render('structures/editor', ['editorialDossier' => $editorialDossier?->id, 'structure' => $structure, 'record' => $record,
return Inertia::render('structures/editor', ['editorialDossier' => $editorialDossier?->id, 'editorialSubject' => ! $editorialDossier && $record && app(PortalEditorialSubjects::class)->available() ? '/management/editorial/subjects/'.$structure.'/'.$id : null, 'structuralEditorial' => app(PortalEditorialSubjects::class)->available(), 'structure' => $structure, 'record' => $record,
'projects' => $structure === 'clusters' && $id ? Project::orderBy('name')->get(['id', 'name', 'code']) : [],
'chains' => $structure === 'clusters' && $id ? ValueChain::where('cluster_id', $id)->orderBy('code')->get() : [],
'gaps' => $structure === 'clusters' && $id ? InvestmentGap::where('cluster_id', $id)->orderBy('created_at')->get() : [],
'chains' => $structure === 'clusters' && $id ? ValueChain::where('cluster_id', $id)->orderBy('code')->get()->map(fn ($row) => app(PortalEditorialSubjects::class)->available() ? app(PortalEditorialSubjects::class)->working('value-chains', (object) $row->getAttributes()) : $row) : [],
'gaps' => $structure === 'clusters' && $id ? InvestmentGap::where('cluster_id', $id)->orderBy('created_at')->get()->map(fn ($row) => app(PortalEditorialSubjects::class)->available() ? app(PortalEditorialSubjects::class)->working('investment-gaps', (object) $row->getAttributes()) : $row) : [],
'chainLinks' => $structure === 'clusters' && $id ? DB::table('value_chain_links')
->join('value_chains as origins', 'origins.id', '=', 'value_chain_links.from_value_chain_id')
->join('value_chains as destinations', 'destinations.id', '=', 'value_chain_links.to_value_chain_id')
@@ -70,7 +74,7 @@ class InvestmentStructureController extends Controller
'memberships' => $structure === 'clusters' && $id ? DB::table('cluster_projects')
->join('projects', 'projects.id', '=', 'cluster_projects.project_id')
->where('cluster_id', $id)->whereNull('cluster_projects.deleted_at')->whereNull('projects.deleted_at')
->orderBy('projects.name')->get(['cluster_projects.id', 'cluster_projects.project_id', 'cluster_projects.role', 'cluster_projects.association_type', 'cluster_projects.source_reference', 'cluster_projects.evidence_reference', 'cluster_projects.valid_from', 'cluster_projects.valid_until', 'cluster_projects.validation_status', 'cluster_projects.version_no', 'cluster_projects.editor_id', 'projects.name']) : [],
->orderBy('projects.name')->get(['cluster_projects.id', 'cluster_projects.project_id', 'cluster_projects.role', 'cluster_projects.association_type', 'cluster_projects.source_reference', 'cluster_projects.evidence_reference', 'cluster_projects.valid_from', 'cluster_projects.valid_until', 'cluster_projects.validation_status', 'cluster_projects.version_no', 'cluster_projects.editor_id', 'projects.name'])->map(fn ($row) => app(PortalEditorialSubjects::class)->working('cluster-project', $row)) : [],
]);
}
@@ -82,12 +86,13 @@ class InvestmentStructureController extends Controller
$model = $this->model($structure);
$record = $id ? $model::findOrFail($id) : new $model;
$editorialDossier = $id ? app(PortalEditorialWorkflow::class)->structureDossier($structure, $id, true) : null;
$subjectWrites = ! $editorialDossier && ($id ? app(PortalEditorialSubjects::class)->writes($structure, $id) : app(PortalEditorialSubjects::class)->available());
$rules = [
'code' => ['required', 'string', 'max:100', Rule::unique($record->getTable(), 'code')->whereNull('deleted_at')->ignore($record->getKey())],
'name' => ['required', 'string', 'max:200'],
'status' => ['required', Rule::in($isProject ? ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'] : ($isCluster ? ['planned', 'active', 'inactive'] : ['proposed', 'active', 'closed']))],
];
if ($editorialDossier) {
if ($editorialDossier || ($subjectWrites && $id)) {
$rules['editorial_version'] = ['required', 'integer', 'min:1'];
}
if ($isProject || $isCorridor) {
@@ -119,6 +124,17 @@ class InvestmentStructureController extends Controller
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Dados-base guardados numa edição privada da carteira. A publicação anterior está preservada.');
}
if ($subjectWrites) {
DB::transaction(function () use ($request, $record, $structure, $id, $data): void {
$fields = array_diff_key($data, ['editorial_version' => true]);
if (! $id) {
PortalAudit::save($record, $fields, $request->user()->id);
}
app(PortalEditorialWorkflow::class)->saveSubject($request->user(), $structure, (string) $record->getKey(), $id ? (int) $data['editorial_version'] : 0, $fields, ! $id);
});
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Proposta privada guardada. Reveja a edição antes de publicar as alterações.');
}
PortalAudit::save($record, $data, $request->user()->id);
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.');
@@ -137,6 +153,7 @@ class InvestmentStructureController extends Controller
public function linkProject(Request $request, EconomicCluster $cluster): RedirectResponse
{
$editorial = app(PortalEditorialSubjects::class)->available();
$data = $request->validate(['project_id' => ['required', 'uuid', Rule::exists('projects', 'id')->whereNull('deleted_at')],
'version_no' => ['required', 'integer', 'min:0'],
'role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])],
@@ -144,10 +161,21 @@ class InvestmentStructureController extends Controller
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'evidence_reference' => ['required', 'string', 'min:3', 'max:2000'],
'valid_from' => ['required', 'date_format:Y-m-d'],
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from']]);
DB::transaction(function () use ($cluster, $data, $request) {
'valid_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:valid_from'],
...($editorial ? ['editorial_version' => ['required', 'integer', 'min:0']] : [])]);
DB::transaction(function () use ($cluster, $data, $request, $editorial) {
EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail();
$link = ClusterProject::where('cluster_id', $cluster->id)->where('project_id', $data['project_id'])->whereNull('deleted_at')->lockForUpdate()->first();
if ($editorial || ($link && app(PortalEditorialSubjects::class)->writes('cluster-project', $link->id))) {
abort_unless((int) $data['version_no'] === ($link ? (int) $link->version_no : 0), 409, 'A associação foi alterada. Confira a versão actual.');
$fields = [...array_diff_key($data, ['version_no' => true, 'editorial_version' => true]), 'cluster_id' => $cluster->id];
if (! $link) {
$fields += ['validation_status' => 'pending', 'editor_id' => $request->user()->id, 'validator_id' => null, 'version_no' => 1];
}
app(PortalEditorialSubjects::class)->stageModel($request->user(), 'cluster-project', $link ?? new ClusterProject, $fields, (int) $data['editorial_version']);
return;
}
if ($link) {
abort_if((int) $link->version_no !== (int) $data['version_no'], 409, 'Versao da associacao alterada.');
$changed = false;
@@ -178,10 +206,17 @@ class InvestmentStructureController extends Controller
public function saveChain(Request $request, EconomicCluster $cluster, ?ValueChain $chain = null): RedirectResponse
{
abort_if($chain && $chain->cluster_id !== $cluster->id, 404);
$editorial = $chain ? app(PortalEditorialSubjects::class)->writes('value-chains', $chain->id) : app(PortalEditorialSubjects::class)->available();
$data = $request->validate(['code' => ['required', 'string', 'max:100',
Rule::unique('value_chains', 'code')->where('cluster_id', $cluster->id)->whereNull('deleted_at')->ignore($chain?->id)],
'name' => ['required', 'string', 'max:200'],
'stage' => ['required', Rule::in(['input', 'production', 'processing', 'logistics', 'market'])]]);
'stage' => ['required', Rule::in(['input', 'production', 'processing', 'logistics', 'market'])],
...($editorial && $chain ? ['editorial_version' => ['required', 'integer', 'min:1']] : [])]);
if ($editorial) {
$edition = app(PortalEditorialSubjects::class)->stageModel($request->user(), 'value-chains', $chain ?? new ValueChain, [...array_diff_key($data, ['editorial_version' => true]), 'cluster_id' => $cluster->id], $chain ? (int) $data['editorial_version'] : 0);
return redirect('/management/editorial/subjects/value-chains/'.$edition->subject_key)->with('success', 'Etapa guardada numa proposta privada.');
}
PortalAudit::save($chain ?? new ValueChain, [...$data, 'cluster_id' => $cluster->id], $request->user()->id);
return back()->with('success', 'Etapa da cadeia de valor guardada.');
@@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -30,7 +32,8 @@ class InvestmentStructureRelationController extends Controller
...($kind === 'cluster-project' ? ['role' => ['required', Rule::in(['anchor', 'complementary', 'supporting'])]] : []),
]);
DB::transaction(function () use ($data, $table, $left, $right, $leftTable, $request): void {
$proposal = null;
DB::transaction(function () use ($data, $table, $left, $right, $leftTable, $request, $kind, &$proposal): void {
DB::table($leftTable)->where('id', $data[$left])->lockForUpdate()->firstOrFail();
$existing = DB::table($table)->where($left, $data[$left])->where($right, $data[$right])->whereNull('deleted_at')->lockForUpdate()->first();
abort_if($existing !== null, 409, 'Associacao ja registada.');
@@ -40,8 +43,15 @@ class InvestmentStructureRelationController extends Controller
$id = (string) Str::uuid();
DB::table($table)->insert([...$values, 'id' => $id, 'source_system' => 'portal-management']);
$this->audit($request, $table, $id, 'submitted', 1);
if (app(PortalEditorialSubjects::class)->available()) {
$proposal = app(PortalEditorialWorkflow::class)->saveSubject($request->user(), $kind, $id, 0, $data, true);
}
});
if ($proposal) {
return redirect('/management/editorial/subjects/'.$kind.'/'.$proposal->subject_key)->with('success', 'Associação guardada numa proposta privada.');
}
return back()->with('success', 'Associacao submetida para revisao independente.');
}
@@ -50,6 +60,12 @@ class InvestmentStructureRelationController extends Controller
abort_unless(in_array($action, ['validate', 'revoke'], true), 404);
[$table] = $this->definition($kind);
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
if (app(PortalEditorialSubjects::class)->writes($kind, $id)) {
app(PortalEditorialSubjects::class)->authorize($request->user(), $kind, $id, true);
abort_if($request->expectsJson(), 409, 'Continue em /management/editorial/subjects/'.$kind.'/'.$id.' para rever a edição e publicar a decisão separadamente.');
return redirect('/management/editorial/subjects/'.$kind.'/'.$id)->with('success', 'Prepare ou confira a edição antes de decidir. Para retirar, marque a proposta de retirada.');
}
DB::transaction(function () use ($request, $table, $id, $action, $data): void {
$relation = DB::table($table)->where('id', $id)->lockForUpdate()->first();
@@ -5,9 +5,12 @@ namespace App\Http\Controllers;
use App\Models\DevelopmentCorridor;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\InvestmentZone;
use App\Models\Opportunity;
use App\Models\Project;
use App\Services\InvestmentZoneOperationalProfile;
use App\Services\InvestmentZones;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use App\Services\TerritoryGeometry;
@@ -92,6 +95,12 @@ class InvestmentTerritoryController extends Controller
$working = app(PortalEditorialWorkflow::class)->workingStructure($model::findOrFail($id), $dossier);
$record->primary_territory_id = $working->getAttribute('primary_territory_id');
$record->territory_version = $working->getAttribute('territory_version');
} elseif (in_array($kind, ['projects', 'clusters', 'corridors'], true) && app(PortalEditorialSubjects::class)->available()) {
$record = app(PortalEditorialSubjects::class)->working($kind, $record);
} elseif ($kind === 'zones' && app(InvestmentZoneOperationalProfile::class)->installed()) {
$working = app(InvestmentZoneOperationalProfile::class)->management(InvestmentZone::findOrFail($id));
$record->primary_territory_id = $working['primary_territory_id'];
$record->territory_version = $working['territory_version'];
}
$path = [];
$current = $record->primary_territory_id;
@@ -115,17 +124,26 @@ class InvestmentTerritoryController extends Controller
}
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'path' => ['present', 'array', 'max:4'], 'path.*' => ['required', 'uuid', 'distinct']]);
$dossier = app(PortalEditorialWorkflow::class)->structureDossier($kind, $id, true);
$version = DB::transaction(function () use ($request, $table, $id, $data, $dossier, $kind) {
$structural = ! $dossier && in_array($kind, ['projects', 'clusters', 'corridors'], true) && app(PortalEditorialSubjects::class)->writes($kind, $id);
$version = DB::transaction(function () use ($request, $table, $id, $data, $dossier, $kind, $structural) {
if ($dossier) {
$dossier = InvestmentDossier::whereKey($dossier->id)->lockForUpdate()->firstOrFail();
}
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
if ($structural) {
app(PortalEditorialSubjects::class)->lock($kind, $id, ['primary_territory_id' => $data['path'] === [] ? null : $data['path'][array_key_last($data['path'])]]);
}
$record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($record !== null, 404);
$effectiveVersion = (int) $record->territory_version;
if ($dossier) {
$model = ['projects' => Project::class, 'clusters' => EconomicCluster::class, 'corridors' => DevelopmentCorridor::class][$kind] ?? abort(404);
$effectiveVersion = (int) app(PortalEditorialWorkflow::class)->workingStructure($model::findOrFail($id), $dossier)->getAttribute('territory_version');
} elseif ($structural) {
$effectiveVersion = (int) app(PortalEditorialSubjects::class)->working($kind, $record)->territory_version;
} elseif ($kind === 'zones' && app(InvestmentZoneOperationalProfile::class)->installed()) {
$working = app(InvestmentZoneOperationalProfile::class)->management(InvestmentZone::findOrFail($id));
$effectiveVersion = (int) $working['territory_version'];
}
abort_unless($effectiveVersion === $data['version'], 409, 'Localização alterada. Recarregue a ficha.');
$parent = null;
@@ -145,13 +163,20 @@ class InvestmentTerritoryController extends Controller
return $version;
}
if ($structural) {
$workflow = app(PortalEditorialWorkflow::class);
$workflow->saveSubject($request->user(), $kind, $id, $workflow->subjectVersion($kind, $id), ['primary_territory_id' => $parent, 'territory_version' => $version]);
return $version;
}
$changes = ['primary_territory_id' => $parent, 'territory_version' => $version, 'updated_at' => now()];
if (in_array($table, ['opportunities', 'investment_zones'], true)) {
$changes['province'] = $names[0] ?? null;
$changes['district'] = $names[1] ?? null;
}
if ($table === 'investment_zones') {
app(InvestmentZones::class)->save($id, [...$changes, 'version_no' => $record->version_no], $request->user()->id);
$workingVersion = app(InvestmentZoneOperationalProfile::class)->workingVersion(InvestmentZone::findOrFail($id));
app(InvestmentZones::class)->save($id, [...$changes, 'version_no' => $workingVersion], $request->user()->id);
} else {
DB::table($table)->where('id', $id)->update($changes);
}
@@ -162,6 +187,8 @@ class InvestmentTerritoryController extends Controller
return $version;
});
return response()->json(['version' => $version, 'pending_publication' => $dossier !== null]);
$zoneDraft = $kind === 'zones' && (app(InvestmentZoneOperationalProfile::class)->editorialEnabled() || DB::table('investment_zones')->where('id', $id)->value('category') === 'industrial_park');
return response()->json(['version' => $version, 'pending_publication' => $dossier !== null || $structural || $zoneDraft]);
}
}
@@ -19,6 +19,8 @@ use App\Services\InvestmentZones;
use App\Services\PublicSearchMetrics;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
@@ -291,6 +293,13 @@ class InvestmentZoneController extends Controller
return response()->json(app(InvestmentZoneOperationalProfile::class)->management($saved));
}
public function editorialReview(Request $request, string $id, string $action): JsonResponse
{
$data = $request->validate(['version_no' => 'required|integer|min:1', 'reason' => 'required|string|min:10|max:3000']);
return response()->json(app(InvestmentZoneOperationalProfile::class)->review($id, $request->user(), (int) $data['version_no'], $action, $data['reason']));
}
public function revisions(string $id): JsonResponse
{
InvestmentZone::findOrFail($id);
@@ -298,6 +307,30 @@ class InvestmentZoneController extends Controller
return response()->json(DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->paginate(20, ['id', 'version_no', 'actor_id', 'action', 'sha256', 'created_at']));
}
public function revision(Request $request, string $id, int $version): JsonResponse
{
abort_unless(app(InvestmentZoneOperationalProfile::class)->reviewAccess($request->user()), 403);
InvestmentZone::findOrFail($id);
$revision = DB::table('investment_zone_revisions')->where('zone_id', $id)->where('version_no', $version)->first();
abort_unless($revision !== null, 404);
try {
$json = Crypt::decryptString($revision->snapshot);
abort_unless(hash_equals($revision->sha256, hash('sha256', $json)), 409);
$snapshot = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
$fields = $snapshot['draft']['fields'] ?? $snapshot['public'] ?? $snapshot;
$details = $fields['operational_details'] ?? [];
if (isset($details['private_ciphertext'])) {
$details = json_decode(Crypt::decryptString($details['private_ciphertext']), true, 512, JSON_THROW_ON_ERROR);
}
} catch (\Throwable $e) {
abort(409, 'Não foi possível conferir esta versão. O histórico foi preservado.');
}
return response()->json(['version_no' => $revision->version_no, 'action' => $revision->action,
'fields' => Arr::only($fields, ['name', 'description']),
'observations' => array_map(fn ($row) => Arr::only($row, ['label', 'value', 'unit', 'scope', 'period_start', 'period_end', 'source_reference']), $details['integral']['observations'] ?? [])]);
}
public function validateLocation(Request $request, InvestmentZones $zones, string $id): JsonResponse
{
InvestmentZoneAccess::requireRole($request->user(), 'territory_reviewers');
@@ -3,6 +3,8 @@
namespace App\Http\Controllers;
use App\Models\InvestmentZone;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -36,14 +38,28 @@ class InvestmentZoneRelationController extends Controller
DB::table('investment_zone_relations')->insert([...$data, 'id' => $relation, 'zone_id' => $id,
'editor_id' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
$this->audit($relation, $request->user()->id, 'created', 1);
if (app(PortalEditorialSubjects::class)->available()) {
app(PortalEditorialWorkflow::class)->saveSubject($request->user(), 'zone-relations', $relation, 0, [...$data, 'zone_id' => $id], true);
}
return response()->json(DB::table('investment_zone_relations')->where('id', $relation)->first(), 201);
$record = DB::table('investment_zone_relations')->where('id', $relation)->first();
if (app(PortalEditorialSubjects::class)->available()) {
$record->editorial_url = '/management/editorial/subjects/zone-relations/'.$relation;
$record->pending_publication = true;
}
return response()->json($record, 201);
});
}
public function review(Request $request, string $id, string $action): JsonResponse
{
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1']]);
if (app(PortalEditorialSubjects::class)->writes('zone-relations', $id)) {
app(PortalEditorialSubjects::class)->authorize($request->user(), 'zone-relations', $id, true);
return response()->json(['pending_publication' => true, 'editorial_url' => '/management/editorial/subjects/zone-relations/'.$id]);
}
return DB::transaction(function () use ($request, $id, $action, $data) {
$relation = DB::table('investment_zone_relations')->where('id', $id)->lockForUpdate()->first();
@@ -5,11 +5,15 @@ namespace App\Http\Controllers;
use App\Models\User;
use App\Services\ContentManagement;
use App\Services\DocumentManagementAccess;
use App\Services\PortalEditorialNativeNotices;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use App\Services\PortalProfileAccess;
use App\Services\TerritorialRegistry;
use App\Services\VerifiedDocumentBinary;
use App\Support\CheckedFile;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
@@ -17,6 +21,64 @@ use Symfony\Component\HttpFoundation\Response;
final class PortalEditorialWorkflowController extends Controller
{
public function subjects(Request $request, PortalEditorialWorkflow $workflow): Response
{
$adapter = app(PortalEditorialSubjects::class);
abort_unless($adapter->available() && $request->user()->can('manage-portal') && ! app(PortalProfileAccess::class)->has($request->user(), 'auditor'), 403);
$items = $workflow->subjectQueue()->orderByDesc('e.updated_at')->paginate(25, ['e.id', 'e.subject_kind', 'e.subject_key', 'e.revision_no', 'e.state', DB::raw("e.payload->'resource'->>'title' AS title")]);
return $this->screen($request, 'management/editorial-subjects', ['items' => $items]);
}
public function subject(Request $request, string $kind, string $id, PortalEditorialWorkflow $workflow): Response
{
$adapter = app(PortalEditorialSubjects::class);
$adapter->authorize($request->user(), $kind, $id);
$record = $adapter->query($kind, $id)->firstOrFail();
$selected = $request->validate(['edition' => 'nullable|uuid'])['edition'] ?? null;
$latest = $workflow->latestSubject($kind, $id);
$editions = DB::table('portal_editorial_editions')->where('subject_kind', $kind)->where('subject_key', $id);
$edition = $selected ? (clone $editions)->where('id', $selected)->first() : $latest;
abort_if($selected && ! $edition, 404);
$payload = $edition ? $workflow->payload($edition) : null;
$independent = $edition ? $workflow->requiresIndependent($edition) : $adapter->independent($kind);
$canApprove = ! $independent || ! in_array($request->user()->id, $payload['contributors'] ?? [], true);
return $this->screen($request, 'management/editorial-edition', ['subject' => 'structural', 'subjectKind' => $kind, 'editUrl' => $adapter->editUrl($kind, $record), 'subjectFields' => $adapter->form($kind), 'subjectInitial' => Arr::only(get_object_vars($record), $adapter->definition($kind)['fields']),
'resource' => ['id' => $id, 'title' => $record->name ?? $record->title ?? $kind, 'published' => ($record->deleted_at ?? null) === null && ($record->validation_status ?? null) !== 'revoked' && (clone $editions)->where('state', 'published')->exists(), 'public_edition_no' => (clone $editions)->where('state', 'published')->max('revision_no')],
'edition' => $edition ? [...get_object_vars($edition), 'payload' => $payload, 'fresh' => $edition->state === 'published' || $workflow->fresh($edition)] : null,
'current' => $edition && $edition->id === $latest?->id, 'independentRequired' => $independent, 'canApprove' => $canApprove, '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->subject_kind', $kind)->where('after_data->subject_key', $id)->orderByDesc('id')->get(['id', 'action', 'actor_user_id', 'after_data', 'occurred_at']),
'editions' => $editions->orderByDesc('revision_no')->get(['id', 'revision_no', 'state', 'content_hash', 'author_id', 'created_at'])]);
}
public function subjectTransition(Request $request, string $kind, string $id, string $edition, PortalEditorialWorkflow $workflow): Response
{
$result = $workflow->transition($request->user(), $id, $edition, $request->all(), false, false, $kind);
return $request->expectsJson() ? response()->json(['edition' => $result])->header('Cache-Control', 'private, no-store') : back()->with('success', 'Operação editorial registada.');
}
public function subjectSave(Request $request, string $kind, string $id, PortalEditorialWorkflow $workflow): Response
{
$adapter = app(PortalEditorialSubjects::class);
$adapter->authorize($request->user(), $kind, $id, true);
$data = $request->validate(['expected_revision' => 'required|integer|min:1', 'fields' => 'required|array', 'withdraw' => 'sometimes|boolean']);
$edition = $workflow->saveSubject($request->user(), $kind, $id, (int) $data['expected_revision'], $adapter->validate($kind, $data['fields']), false, $data['withdraw'] ?? null);
return $request->expectsJson() ? response()->json(['edition' => $edition], 201)->header('Cache-Control', 'private, no-store') : redirect('/management/editorial/subjects/'.$kind.'/'.$id)->with('success', 'Proposta privada guardada.');
}
public function subjectChoices(Request $request, string $kind, string $id): Response
{
$adapter = app(PortalEditorialSubjects::class);
$adapter->authorize($request->user(), $kind, $id);
$data = $request->validate(['field' => 'required|string|max:80', 'q' => 'nullable|string|max:100']);
return response()->json(['data' => $adapter->choices($kind, $data['field'], $data['q'] ?? null), 'limit' => 100])->header('Cache-Control', 'private, no-store');
}
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 */
@@ -45,7 +107,9 @@ final class PortalEditorialWorkflowController extends Controller
$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,
'portfolioUrl' => $destinations['portfolio']['index'] ?? null, 'portfolioPending' => $workflow->portfolioPending($actor), 'demographyUrl' => $destinations['demography']['index'] ?? null, 'demographyPending' => $workflow->demographyPending($actor),
'territorialUrl' => app(TerritorialRegistry::class)->editorialAvailable() && $actor->can('manage-portal') ? '/management/territorial/editorial' : null];
'territorialUrl' => app(TerritorialRegistry::class)->editorialAvailable() && $actor->can('manage-portal') ? '/management/territorial/editorial' : null,
'nativePending' => app(PortalEditorialNativeNotices::class)->pending($actor), 'zonesUrl' => app(PortalEditorialNativeNotices::class)->access($actor, 'investment-zones') ? '/management/investment/zones' : null,
'subjectsUrl' => app(PortalEditorialSubjects::class)->available() && $actor->can('manage-portal') ? '/management/editorial/subjects' : null, 'subjectsPending' => $workflow->subjectPending($actor)];
return $this->screen($request, 'management/editorial-workflow', $data);
}
@@ -67,7 +131,7 @@ final class PortalEditorialWorkflowController extends Controller
'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),
'legalReviewer' => $workflow->legalReviewer($request->user()),
'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'])];
@@ -19,7 +19,9 @@ class TerritorialRegistryController extends Controller
$actor = $request->user();
$roles = array_values(array_filter(['editors', 'reviewers', 'publishers'], fn ($role) => in_array(strtolower($actor->email), config('territorial.'.$role, []), true)));
return Inertia::render('management/territorial-editorial', ['roles' => $roles, 'actorId' => $actor->id])->toResponse($request);
$context = $request->validate(['territory' => 'nullable|uuid', 'notice_version' => 'nullable|integer|min:1']);
return Inertia::render('management/territorial-editorial', ['roles' => $roles, 'actorId' => $actor->id, 'initialTerritory' => $context['territory'] ?? null, 'noticeVersion' => $context['notice_version'] ?? null])->toResponse($request);
}
public function preview(Request $request, TerritorialRegistry $registry, string $id): JsonResponse
+8 -2
View File
@@ -9,6 +9,7 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class ClusterCompetitiveness
@@ -140,13 +141,18 @@ final class ClusterCompetitiveness
throw new \LogicException('Unsupported competitiveness action');
}
$state['version']++;
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'economic_clusters',
$eventId = (string) Str::uuid();
DB::table('audit_events')->insert(['id' => $eventId, 'source_system' => self::SOURCE, 'entity_type' => 'economic_clusters',
'entity_id' => $cluster->id, 'external_id' => $cluster->id.':'.$state['version'], 'actor_user_id' => $actor,
'action' => $action, 'data_classification' => 'restricted',
'before_data' => json_encode(['version' => $before['version']], JSON_THROW_ON_ERROR),
'after_data' => json_encode(['version' => $state['version'], 'restored_from' => $input['revision'] ?? null,
'after_data' => json_encode(['version' => $state['version'], 'editorial_state' => $state['workflow']['state'] ?? null, 'restored_from' => $input['revision'] ?? null,
'reason' => Crypt::encryptString($input['reason'] ?? ''),
'snapshot' => Crypt::encryptString(json_encode($state, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]);
if ($this->editorialEnabled()) {
$user = User::findOrFail($actor);
app(PortalEditorialNativeNotices::class)->emit($user, $eventId, $state['workflow']['contributors']);
}
return $this->management($cluster->id);
});
@@ -3,11 +3,14 @@
namespace App\Services;
use App\Models\InvestmentZone;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Editorial preparation in the existing master. No operational/land entitlement is inferred. */
class InvestmentZoneOperationalProfile
@@ -16,7 +19,7 @@ class InvestmentZoneOperationalProfile
public const FIELDS = ['code', 'category', 'name', 'name_en', 'description', 'description_en', 'province', 'district', 'operator_id', 'legal_source_id',
'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_components', 'source_evidence',
'area_value', 'area_unit', 'aliases', 'activities', 'resources', 'data_as_of', 'sources_consulted_at', 'parent_zone_id', 'historical_territory'];
'area_value', 'area_unit', 'aliases', 'activities', 'resources', 'data_as_of', 'sources_consulted_at', 'parent_zone_id', 'historical_territory', 'primary_territory_id', 'territory_version'];
public const DETAIL_PUBLIC = ['id', 'name', 'status', 'source_reference', 'source_date', 'kind', 'scope', 'capacity_type', 'capacity_value', 'capacity_unit', 'focus', 'economic_activity_id'];
@@ -25,6 +28,159 @@ class InvestmentZoneOperationalProfile
return Schema::hasColumn('investment_zones', 'editorial_draft');
}
public function editorialEnabled(): bool
{
return (bool) config('editorial_workflow.zone_review') && $this->installed();
}
public function ensureWriteMode(InvestmentZone $zone): void
{
if (config('editorial_workflow.zone_review')) {
abort_unless($this->editorialEnabled(), 503, 'A preparação privada das zonas ainda não está instalada.');
} elseif ($zone->exists) {
abort_if(DB::table('audit_events')->where('source_system', 'investment-zones')->where('entity_id', $zone->id)->where('after_data->editorial_workflow', true)->exists(), 503, 'Reactive a revisão das zonas para preservar o histórico.');
}
}
public function reviewAccess(User $actor): bool
{
$access = app(PortalProfileAccess::class);
return $actor->hasVerifiedEmail() && $actor->two_factor_confirmed_at && $actor->two_factor_secret && ! $access->has($actor, 'auditor')
&& ($actor->can('manage-portal') || $access->superuser($actor) || $access->legacyWriteAllowed($actor));
}
private function reviewActor(User $actor): void
{
abort_unless($this->reviewAccess($actor) && app(PortalProfileAccess::class)->mfa($actor), 403, 'Âmbito administrativo e MFA válido necessários.');
}
public function authorizeWrite(int $actor): void
{
$this->reviewActor(User::findOrFail($actor));
}
/** @param array<array-key,mixed> $draft */
private function reviewHash(InvestmentZone $zone, array $draft): string
{
$context = ['public_fields' => Arr::only($zone->getRawOriginal(), [...self::FIELDS, 'primary_territory_id', 'territory_version', 'operational_details']), 'locations' => [], 'documents' => []];
foreach (['legal_source_id' => 'investment_legal_sources', 'operator_id' => 'organizations', 'primary_territory_id' => 'geo_locations'] as $field => $table) {
$id = $draft['fields'][$field] ?? $zone->getAttribute($field);
if ($id) {
$row = DB::table($table)->where('id', $id)->first();
abort_unless($row !== null, 422, 'Fonte ou contexto da zona indisponível.');
$context[$field] = Arr::except(get_object_vars($row), ['created_at', 'updated_at']);
}
}
$context['locations'] = DB::table('investment_zone_locations')->where('zone_id', $zone->id)->orderBy('id')->get()->map(fn ($row) => Arr::except(get_object_vars($row), ['created_at', 'updated_at']))->all();
$geography = [];
$locations = array_filter([$draft['fields']['primary_territory_id'] ?? $zone->getAttribute('primary_territory_id'), ...array_column($context['locations'], 'geo_location_id')]);
foreach (array_unique($locations) as $location) {
$seen = [];
while ($location) {
abort_if(in_array($location, $seen, true) || count($seen) >= 5, 422, 'Hierarquia territorial incoerente.');
$seen[] = $location;
$node = DB::table('geo_locations')->where('id', $location)->whereNull('deleted_at')->first();
abort_unless($node !== null, 422, 'Contexto territorial indisponível.');
$geography[$node->id] = Arr::only(get_object_vars($node), ['id', 'name', 'parent_id', 'location_type', 'version_no', 'data_classification', 'source_system', 'source_code']);
$location = $node->parent_id;
}
}
ksort($geography);
$context['geography'] = $geography;
foreach (DB::table('investment_zone_attachments')->where('zone_id', $zone->id)->orderBy('id')->get() as $attachment) {
$document = DB::table('documents')->where('id', $attachment->document_id)->first();
abort_unless($document !== null, 422, 'Anexo indisponível.');
if ($document->status === 'active' && $document->malware_scan_status === 'clean' && $document->deleted_at === null) {
app(VerifiedDocumentBinary::class)->respond($document);
}
$context['documents'][] = ['attachment' => Arr::except(get_object_vars($attachment), ['created_at', 'updated_at']), 'document' => Arr::only(get_object_vars($document), ['id', 'sha256', 'version_no', 'status', 'deleted_at', 'rights_status', 'malware_scan_status', 'malware_scanned_sha256'])];
}
return hash('sha256', json_encode(Arr::sortRecursive(['fields' => $draft['fields'], 'context' => $context, 'contributors' => $draft['editorial']['contributors'] ?? []]), JSON_THROW_ON_ERROR));
}
/** @param array<array-key,mixed> $draft */
private function freshReview(InvestmentZone $zone, array $draft): bool
{
try {
return (int) $draft['base_public_version'] === (int) $zone->version_no && hash_equals($draft['editorial']['hash'], $this->reviewHash($zone, $draft));
} catch (HttpException $exception) {
if (in_array($exception->getStatusCode(), [404, 409, 422], true)) {
return false;
}
throw $exception;
}
}
/** @param array<array-key,mixed> $workflow */
private function independent(array $workflow): bool
{
return ($workflow['independent_required'] ?? false) || in_array('zone_statistics', config('editorial_workflow.independent_approval.kinds', []), true);
}
/** @param array<array-key,mixed> $draft
* @param array<array-key,mixed>|null $previous
* @return array<array-key,mixed>
*/
private function newReview(InvestmentZone $zone, array $draft, int $actor, ?array $previous): array
{
$draft['editorial'] = ['state' => 'draft', 'content_version' => $draft['working_version'], 'contributors' => array_values(array_unique([...($previous['editorial']['contributors'] ?? []), $actor])),
'independent_required' => ($previous['editorial']['independent_required'] ?? false) || in_array('zone_statistics', config('editorial_workflow.independent_approval.kinds', []), true), 'approval' => null];
$draft['editorial']['hash'] = $this->reviewHash($zone, $draft);
return $draft;
}
/** @return array<array-key,mixed> */
public function review(string $id, User $actor, int $expected, string $action, string $reason): array
{
$this->reviewActor($actor);
validator(['reason' => $reason], ['reason' => 'required|string|min:10|max:3000'])->validate();
abort_unless($this->editorialEnabled(), 503);
return DB::transaction(function () use ($id, $actor, $expected, $action, $reason) {
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$draft = $this->draft($zone);
abort_unless($expected === $this->workingVersion($zone), 409, 'A preparação mudou. Conserve os campos e confira a versão actual.');
if (! $draft || ! isset($draft['editorial'])) {
abort_unless($action === 'submit', 409, 'Prepare a revisão antes de decidir.');
$zone = $this->prepare($zone, ['version_no' => $expected], $actor->id);
$draft = $this->draft($zone);
}
abort_unless($draft !== null && isset($draft['editorial']), 409);
$workflow = $draft['editorial'];
$allowed = ['submit' => ['draft', 'returned'], 'start_review' => ['submitted'], 'approve' => ['in_review'], 'return' => ['in_review'], 'reject' => ['in_review'], 'archive' => ['draft', 'submitted', 'in_review', 'approved', 'returned', 'rejected']];
abort_unless(isset($allowed[$action]) && in_array($workflow['state'], $allowed[$action], true), 409);
if (in_array($action, ['submit', 'approve'], true)) {
abort_unless($this->freshReview($zone, $draft), 409, 'Conteúdo, fonte ou anexos alterados. Guarde nova edição e volte a conferir.');
}
if ($action === 'approve') {
abort_if($this->independent($workflow) && in_array($actor->id, $workflow['contributors'], true), 403, 'Esta edição exige aprovação independente.');
$workflow['approval'] = ['actor_id' => $actor->id, 'content_version' => $workflow['content_version'], 'hash' => $workflow['hash'], 'reason' => $reason, 'at' => now()->toISOString()];
}
$workflow['state'] = ['submit' => 'submitted', 'start_review' => 'in_review', 'approve' => 'approved', 'return' => 'returned', 'reject' => 'rejected', 'archive' => 'archived'][$action];
$draft['editorial'] = $workflow;
$draft['working_version'] = $this->nextVersion($zone);
DB::table('investment_zones')->where('id', $id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]);
$this->snapshot($zone, $draft['working_version'], $actor->id, 'zone_editorial_'.$action, ['draft' => $draft, 'reason' => $reason]);
return $this->management($zone->fresh());
});
}
public function suspensionPreservesReview(InvestmentZone $zone, int $actor): void
{
$draft = $this->draft($zone);
if (! $this->editorialEnabled() || ! $draft) {
return;
}
$draft['base_public_version'] = (int) $zone->version_no;
$draft['working_version'] = $this->nextVersion($zone);
DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]);
$this->snapshot($zone, $draft['working_version'], $actor, 'zone_editorial_suspension_rebased', ['draft' => $draft]);
}
/**
* @return array<array-key, mixed>|null
*/
@@ -60,8 +216,15 @@ class InvestmentZoneOperationalProfile
public function management(InvestmentZone $zone): array
{
$draft = $this->draft($zone);
$workflow = $draft['editorial'] ?? null;
if ($workflow && $draft) {
$workflow['fresh'] = $this->freshReview($zone, $draft);
$workflow['independent_required'] = $this->independent($workflow);
$viewer = auth()->user();
$workflow['can_approve'] = ! $workflow['independent_required'] || ($viewer instanceof User && ! in_array($viewer->id, $workflow['contributors'], true));
}
return [...$zone->toArray(), ...($draft['fields'] ?? []), 'version_no' => $draft['working_version'] ?? $zone->version_no,
return [...$zone->toArray(), ...($draft['fields'] ?? []), 'version_no' => $draft['working_version'] ?? $zone->version_no, 'editorial_enabled' => $this->editorialEnabled(), 'editorial_workflow' => $workflow,
'public_version_no' => $zone->version_no, 'base_public_version' => $draft['base_public_version'] ?? $zone->version_no,
'has_private_draft' => $draft !== null, 'profile_available' => $this->installed(),
'operational_details' => ($draft['fields']['operational_details'] ?? app(InvestmentZoneOperationalDetails::class)->recover($zone)),
@@ -118,6 +281,10 @@ class InvestmentZoneOperationalProfile
*/
public function prepare(InvestmentZone $zone, array $data, int $actor): InvestmentZone
{
$this->ensureWriteMode($zone);
if ($this->editorialEnabled()) {
$this->reviewActor(User::findOrFail($actor));
}
abort_unless($this->installed(), 503, 'A preparação privada requer a instalação nominal da ficha de parques. A publicação anterior foi preservada.');
abort_unless((int) $data['version_no'] === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de guardar.');
$previous = $this->draft($zone);
@@ -142,19 +309,38 @@ class InvestmentZoneOperationalProfile
}
$draft = ['schema_version' => 1, 'base_public_version' => $previous['base_public_version'] ?? (int) $zone->version_no,
'working_version' => $this->nextVersion($zone), 'fields' => $fields, 'actor_id' => $actor, 'prepared_at' => now()->toIso8601String()];
if ($this->editorialEnabled()) {
$draft = $this->newReview($zone, $draft, $actor, $previous);
}
DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]);
$this->snapshot($zone, $draft['working_version'], $actor, 'profile_prepared', ['public' => [...$zone->toArray(), 'operational_details' => $details->stored($zone)], 'draft' => $draft]);
return $zone->fresh();
}
/** Apply only inside the publication transaction; a failed validation rolls everything back. */
public function promote(InvestmentZone $zone, int $version): void
/** Apply only inside the publication transaction; a failed validation rolls everything back.
* @return array<array-key,mixed>|null
*/
public function promote(InvestmentZone $zone, int $version, ?int $actor = null): ?array
{
$draft = $this->draft($zone);
abort_unless($version === $this->workingVersion($zone), 409, 'A preparação mudou; recupere a versão atual antes de publicar.');
if (! $draft) {
return;
abort_if($this->editorialEnabled(), 409, 'Prepare e aprove a revisão antes de publicar.');
return null;
}
if ($this->editorialEnabled()) {
$this->lockReviewContext($zone, $draft);
$publisher = User::where('id', $actor)->firstOrFail();
$this->reviewActor($publisher);
$review = $draft['editorial'] ?? null;
abort_unless($review && $review['state'] === 'approved' && $this->freshReview($zone, $draft), 409, 'Aprovação desta edição necessária.');
$approval = $review['approval'];
abort_unless($approval && $approval['content_version'] === $review['content_version'] && hash_equals($approval['hash'], $review['hash']), 409, 'A aprovação não corresponde à edição.');
$reviewer = User::where('id', $approval['actor_id'])->first();
abort_unless($reviewer && $this->reviewAccess($reviewer), 403, 'Autoridade da decisão revogada.');
abort_if($this->independent($review) && (in_array($reviewer->id, $review['contributors'], true) || in_array($publisher->id, $review['contributors'], true)), 403, 'Esta edição exige aprovação/publicação independente.');
}
abort_unless((int) $draft['base_public_version'] === (int) $zone->version_no, 409, 'A publicação base mudou. Reconcilie a preparação antes de publicar; ambos os estados foram preservados.');
$zone->fill(array_intersect_key($draft['fields'], array_flip(self::FIELDS)));
@@ -166,10 +352,39 @@ class InvestmentZoneOperationalProfile
$zone->setAttribute($key, $this->publicDetails([$key => $zone->$key ?? []])[$key]);
}
$zone->setAttribute('editorial_draft', null);
return $draft['editorial'] ?? null;
}
/** @param array<array-key,mixed> $draft */
private function lockReviewContext(InvestmentZone $zone, array $draft): void
{
foreach (['legal_source_id' => 'investment_legal_sources', 'operator_id' => 'organizations'] as $field => $table) {
$id = $draft['fields'][$field] ?? $zone->getAttribute($field);
if ($id) {
DB::table($table)->where('id', $id)->lockForUpdate()->first();
}
}
$locations = DB::table('investment_zone_locations')->where('zone_id', $zone->id)->orderBy('id')->lockForUpdate()->pluck('geo_location_id')->all();
$locations[] = $draft['fields']['primary_territory_id'] ?? $zone->getAttribute('primary_territory_id');
$all = [];
foreach (array_filter($locations) as $location) {
for ($i = 0; $location && $i < 5; $i++) {
$all[] = $location;
$location = DB::table('geo_locations')->where('id', $location)->value('parent_id');
}
}
DB::table('geo_locations')->whereIn('id', array_unique($all))->orderBy('id')->lockForUpdate()->get();
$documents = DB::table('investment_zone_attachments')->where('zone_id', $zone->id)->orderBy('id')->lockForUpdate()->pluck('document_id')->all();
DB::table('documents')->whereIn('id', $documents)->orderBy('id')->lockForUpdate()->get();
}
public function reconcile(InvestmentZone $zone, int $working, int $public, int $actor): InvestmentZone
{
$this->ensureWriteMode($zone);
if ($this->editorialEnabled()) {
$this->reviewActor(User::findOrFail($actor));
}
$draft = $this->draft($zone);
abort_unless($draft && $working === (int) $draft['working_version'] && $public === (int) $zone->version_no, 409, 'Versões alteradas; recupere a ficha pública e a preparação antes de reconciliar.');
abort_if((int) $draft['base_public_version'] === $public, 422, 'A preparação já corresponde à versão pública atual.');
@@ -177,6 +392,9 @@ class InvestmentZoneOperationalProfile
$draft['working_version'] = $this->nextVersion($zone);
$draft['actor_id'] = $actor;
$draft['prepared_at'] = now()->toIso8601String();
if ($this->editorialEnabled()) {
$draft = $this->newReview($zone, $draft, $actor, $draft);
}
DB::table('investment_zones')->where('id', $zone->id)->update(['editorial_draft' => Crypt::encryptString(json_encode($draft, JSON_THROW_ON_ERROR))]);
$this->snapshot($zone, $draft['working_version'], $actor, 'profile_reconciled', ['public' => [...$zone->toArray(), 'operational_details' => app(InvestmentZoneOperationalDetails::class)->stored($zone)], 'draft' => $draft]);
@@ -192,7 +410,15 @@ class InvestmentZoneOperationalProfile
$hash = hash('sha256', $json);
DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id, 'version_no' => $version,
'actor_id' => $actor, 'action' => $action, 'snapshot' => Crypt::encryptString($json), 'sha256' => $hash]);
DB::table('audit_events')->insert(['source_system' => 'investment-zones', 'entity_type' => 'investment_zones', 'entity_id' => $zone->id,
'actor_user_id' => $actor, 'action' => $action, 'after_data' => json_encode(['version_no' => $version, 'sha256' => $hash], JSON_THROW_ON_ERROR)]);
$eventId = (string) Str::uuid();
$metadata = ['version_no' => $version, 'sha256' => $hash];
if ($this->editorialEnabled()) {
$metadata += ['editorial_workflow' => true, 'editorial_state' => $data['draft']['editorial']['state'] ?? ($action === 'published' ? 'published' : null)];
}
DB::table('audit_events')->insert(['id' => $eventId, 'source_system' => 'investment-zones', 'entity_type' => 'investment_zones', 'entity_id' => $zone->id,
'actor_user_id' => $actor, 'action' => $action, 'after_data' => json_encode($metadata, JSON_THROW_ON_ERROR)]);
if ($this->editorialEnabled()) {
app(PortalEditorialNativeNotices::class)->emit(User::findOrFail($actor), $eventId, $data['draft']['editorial']['contributors'] ?? $data['decision']['contributors'] ?? []);
}
}
}
+28 -10
View File
@@ -96,9 +96,14 @@ class InvestmentZones
{
return DB::transaction(function () use ($id, $data, $actor) {
$zone = $id ? InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail() : new InvestmentZone;
if ($id && $zone->category === 'industrial_park') {
$prepared = app(InvestmentZoneOperationalProfile::class)->prepare($zone, $data, $actor);
$profile = app(InvestmentZoneOperationalProfile::class);
$profile->ensureWriteMode($zone);
if ($profile->editorialEnabled()) {
$profile->authorizeWrite($actor);
}
if ($id && ($zone->category === 'industrial_park' || $profile->editorialEnabled())) {
$this->storePendingLocations($zone, $data['locations'] ?? [], $actor);
$prepared = app(InvestmentZoneOperationalProfile::class)->prepare($zone, $data, $actor);
return $prepared;
}
@@ -128,8 +133,10 @@ class InvestmentZones
return DB::transaction(function () use ($id, $version, $actor) {
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$profile = app(InvestmentZoneOperationalProfile::class);
if ($zone->category === 'industrial_park') {
$profile->promote($zone, $version);
$profile->ensureWriteMode($zone);
$proof = null;
if ($zone->category === 'industrial_park' || $profile->editorialEnabled()) {
$proof = $profile->promote($zone, $version, $actor);
} else {
abort_if((int) $zone->version_no !== $version, 409, 'Versão alterada. Recarregue antes de aprovar.');
}
@@ -158,9 +165,9 @@ class InvestmentZones
->where('zl.zone_id', $id)->where('zl.validation_status', 'validated')->where('g.data_classification', 'public')->whereNull('g.deleted_at')
->whereIn('g.id', app(InvestmentGeography::class)->publicReferences()->select('id'))->exists();
abort_unless($publicLocation, 422, 'Seleccione uma província ou distrito existente e público.');
$zone->fill(['version_no' => $zone->category === 'industrial_park' ? $profile->nextVersion($zone) : $version + 1, 'reviewer_id' => $actor, 'publication_status' => 'published',
$zone->fill(['version_no' => $zone->category === 'industrial_park' || $profile->editorialEnabled() ? $profile->nextVersion($zone) : $version + 1, 'reviewer_id' => $actor, 'publication_status' => 'published',
'validation_status' => 'validated', 'published_at' => now()])->save();
$this->snapshot($zone, $actor, 'published');
$this->snapshot($zone, $actor, 'published', $proof);
return $zone;
});
@@ -171,11 +178,16 @@ class InvestmentZones
return DB::transaction(function () use ($id, $version, $actor) {
$zone = InvestmentZone::whereKey($id)->lockForUpdate()->firstOrFail();
$profile = app(InvestmentZoneOperationalProfile::class);
abort_if(($zone->category === 'industrial_park' ? $profile->workingVersion($zone) : (int) $zone->version_no) !== $version, 409, 'Versão alterada.');
$zone->fill(['version_no' => $zone->category === 'industrial_park' ? $profile->nextVersion($zone) : $version + 1, 'publication_status' => 'suspended'])->save();
$profile->ensureWriteMode($zone);
if ($profile->editorialEnabled()) {
$profile->authorizeWrite($actor);
}
abort_if(($zone->category === 'industrial_park' || $profile->editorialEnabled() ? $profile->workingVersion($zone) : (int) $zone->version_no) !== $version, 409, 'Versão alterada.');
$zone->fill(['version_no' => $zone->category === 'industrial_park' || $profile->editorialEnabled() ? $profile->nextVersion($zone) : $version + 1, 'publication_status' => 'suspended'])->save();
$this->snapshot($zone, $actor, 'suspended');
$profile->suspensionPreservesReview($zone, $actor);
return $zone;
return $profile->editorialEnabled() ? $zone->fresh() : $zone;
});
}
@@ -203,8 +215,14 @@ class InvestmentZones
});
}
private function snapshot(InvestmentZone $zone, int $actor, string $action): void
/** @param array<array-key,mixed>|null $decision */
private function snapshot(InvestmentZone $zone, int $actor, string $action, ?array $decision = null): void
{
if (app(InvestmentZoneOperationalProfile::class)->editorialEnabled()) {
app(InvestmentZoneOperationalProfile::class)->snapshot($zone, (int) $zone->version_no, $actor, $action, ['public' => [...$zone->toArray(), 'operational_details' => app(InvestmentZoneOperationalDetails::class)->stored($zone)], 'decision' => $decision]);
return;
}
$json = json_encode([...$zone->toArray(), 'operational_details' => app(InvestmentZoneOperationalDetails::class)->stored($zone)], JSON_THROW_ON_ERROR);
DB::table('investment_zone_revisions')->insert(['id' => (string) Str::uuid(), 'zone_id' => $zone->id,
'version_no' => $zone->version_no, 'actor_id' => $actor, 'action' => $action,
@@ -0,0 +1,146 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** Adapts existing immutable native revisions to the common inbox; never changes a workflow. */
final class PortalEditorialNativeNotices
{
public function available(): bool
{
return app(PortalEditorialWorkflow::class)->available() && Schema::hasColumn('portal_editorial_notifications', 'native_event_id');
}
public function access(User $actor, string $source): bool
{
if (! $actor->hasVerifiedEmail() || ! $actor->two_factor_confirmed_at || ! $actor->two_factor_secret
|| app(PortalProfileAccess::class)->has($actor, 'auditor')) {
return false;
}
if ($source === 'investment-zones') {
return app(InvestmentZoneOperationalProfile::class)->editorialEnabled() && app(InvestmentZoneOperationalProfile::class)->reviewAccess($actor);
}
if (! $actor->can('manage-portal')) {
return false;
}
return match ($source) {
'cluster-competitiveness' => app(ClusterCompetitiveness::class)->editorialEnabled(),
'territorial-editorial-v1' => app(TerritorialRegistry::class)->editorialAvailable(),
default => false,
};
}
/** @return array{clusters:int,territories:int,zones:int} */
public function pending(User $actor): array
{
$counts = ['clusters' => 0, 'territories' => 0, 'zones' => 0];
if (! $this->available()) {
return $counts;
}
if ($this->access($actor, 'cluster-competitiveness')) {
$events = DB::table('audit_events as a')->join('economic_clusters as c', 'c.id', '=', 'a.entity_id')
->where('a.source_system', 'cluster-competitiveness')->whereNull('c.deleted_at')
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('audit_events as newer')->whereColumn('newer.entity_id', 'a.entity_id')
->where('newer.source_system', 'cluster-competitiveness')->whereRaw("(newer.after_data->>'version')::integer > (a.after_data->>'version')::integer"))
->select(['a.id', 'a.after_data'])->orderBy('a.id')->cursor();
foreach ($events as $event) {
$payload = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR);
$stage = $payload['editorial_state'] ?? null;
if ($stage === null) {
$state = json_decode(Crypt::decryptString($payload['snapshot']), true, 512, JSON_THROW_ON_ERROR);
$stage = $state['workflow']['state'] ?? null;
}
if ($stage !== null && ! in_array($stage, ['published', 'archived'], true)) {
$counts['clusters']++;
}
}
}
if ($this->access($actor, 'territorial-editorial-v1')) {
$latest = DB::table('audit_events')->where('source_system', 'territorial-editorial-v1')
->selectRaw("DISTINCT ON (entity_id) entity_id,after_data->>'state' AS editorial_state")
->orderBy('entity_id')->orderByRaw("(after_data->>'version_no')::integer DESC");
$counts['territories'] = DB::table('geo_territory_profiles as p')->join('geo_locations as g', 'g.id', '=', 'p.geo_location_id')
->leftJoinSub($latest, 'a', 'a.entity_id', '=', 'g.id')->whereNull('g.deleted_at')
->whereNotIn(DB::raw('COALESCE(a.editorial_state,p.status)'), ['published', 'archived'])->count();
}
if ($this->access($actor, 'investment-zones')) {
$latest = DB::table('audit_events')->where('source_system', 'investment-zones')->where('entity_type', 'investment_zones')->where('after_data->editorial_workflow', true)
->selectRaw("DISTINCT ON (entity_id) entity_id,after_data->>'editorial_state' AS editorial_state")
->orderBy('entity_id')->orderByRaw("(after_data->>'version_no')::integer DESC");
$counts['zones'] = DB::query()->fromSub($latest, 'a')->join('investment_zones as z', 'z.id', '=', 'a.entity_id')->whereNull('z.deleted_at')
->whereIn('a.editorial_state', ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved'])->count();
}
return $counts;
}
/** @param list<int> $participants */
public function emit(User $actor, string $eventId, array $participants): void
{
if (! $this->available()) {
return;
}
$event = DB::table('audit_events')->where('id', $eventId)->first();
abort_unless($event !== null, 409);
if (in_array($event->action, ['saved', 'restored', 'draft_saved', 'profile_prepared', 'profile_reconciled', 'zone_editorial_suspension_rebased'], true)) {
return;
}
$emails = $event->source_system === 'territorial-editorial-v1'
? [...config('territorial.editors', []), ...config('territorial.reviewers', []), ...config('territorial.publishers', [])]
: config('portal.managers', []);
$recipients = User::query()->where(fn ($q) => $q->whereIn('id', $participants)->orWhereIn(DB::raw('lower(email)'), array_map('strtolower', $emails)))->get();
foreach ($recipients as $recipient) {
if ($recipient->id === $actor->id || ! $this->access($recipient, $event->source_system)) {
continue;
}
DB::table('portal_editorial_notifications')->insertOrIgnore(['user_id' => $recipient->id, 'native_event_id' => $eventId,
'edition_id' => null, 'opportunity_id' => null, 'action' => $event->action, 'content_hash' => $this->eventHash($event), 'created_at' => now()]);
}
}
public function visible(User $actor, \stdClass $notice): bool
{
if (! $this->available() || (int) $notice->user_id !== $actor->id) {
return false;
}
$event = DB::table('audit_events')->where('id', $notice->native_event_id)->first();
if (! $event || ! $this->access($actor, $event->source_system) || ! is_string($notice->content_hash)
|| ! hash_equals(trim($notice->content_hash), $this->eventHash($event))) {
return false;
}
$table = match ($event->source_system) {
'cluster-competitiveness' => 'economic_clusters', 'investment-zones' => 'investment_zones', default => 'geo_locations'
};
return DB::table($table)->where('id', $event->entity_id)->whereNull('deleted_at')->exists();
}
private function eventHash(\stdClass $event): string
{
return hash('sha256', json_encode([$event->id, $event->source_system, $event->entity_type, $event->entity_id, $event->action, $event->after_data], JSON_THROW_ON_ERROR));
}
/** @return array<string,mixed> */
public function present(\stdClass $notice): array
{
$event = DB::table('audit_events')->where('id', $notice->native_event_id)->first();
abort_unless($event !== null, 404);
$data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR);
$cluster = $event->source_system === 'cluster-competitiveness';
$version = $data[$cluster ? 'version' : 'version_no'];
if ($event->source_system === 'investment-zones') {
return ['id' => $notice->id, 'action' => str_replace('zone_editorial_', '', $notice->action), 'read_at' => $notice->read_at,
'href' => '/management/investment/zones/'.$event->entity_id.'?notice_version='.$version, 'reference' => 'Zona e indicadores — versão '.$version];
}
return ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at,
'href' => $cluster ? '/management/clusters/'.$event->entity_id.'/edit?notice_version='.$version : '/management/territorial/editorial?territory='.$event->entity_id.'&notice_version='.$version,
'reference' => ($cluster ? 'Competitividade — versão ' : 'Território — versão ').$version];
}
}
+394
View File
@@ -0,0 +1,394 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Subject mapping and publication effects for the existing common editorial engine. */
final class PortalEditorialSubjects
{
public const SUBJECTS = [
'projects' => ['table' => 'projects', 'fields' => ['code', 'name', 'status', 'description', 'budget_amount', 'budget_currency', 'start_date', 'end_date', 'primary_territory_id', 'territory_version']],
'clusters' => ['table' => 'economic_clusters', 'fields' => ['code', 'name', 'status', 'sector', 'primary_territory_id', 'territory_version']],
'corridors' => ['table' => 'development_corridors', 'fields' => ['code', 'name', 'status', 'description', 'primary_territory_id', 'territory_version']],
'cluster-project' => ['table' => 'cluster_projects', 'fields' => ['cluster_id', 'project_id', 'role', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']],
'corridor-project' => ['table' => 'corridor_projects', 'fields' => ['corridor_id', 'project_id', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']],
'corridor-cluster' => ['table' => 'corridor_clusters', 'fields' => ['corridor_id', 'cluster_id', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']],
'value-chains' => ['table' => 'value_chains', 'fields' => ['cluster_id', 'code', 'name', 'stage']],
'value-chain-links' => ['table' => 'value_chain_links', 'fields' => ['from_value_chain_id', 'to_value_chain_id', 'link_type']],
'investment-gaps' => ['table' => 'investment_gaps', 'fields' => ['cluster_id', 'value_chain_id', 'title', 'location', 'required_capacity', 'rationale', 'status', 'call_to_action']],
'zone-relations' => ['table' => 'investment_zone_relations', 'fields' => ['zone_id', 'cluster_id', 'corridor_id', 'project_id', 'association_type', 'role', 'source_reference', 'justification', 'legal_source_id', 'valid_from', 'valid_until', 'priority', 'is_primary']],
];
private const PARENTS = ['cluster_id' => 'economic_clusters', 'project_id' => 'projects', 'corridor_id' => 'development_corridors', 'zone_id' => 'investment_zones',
'value_chain_id' => 'value_chains', 'from_value_chain_id' => 'value_chains', 'to_value_chain_id' => 'value_chains', 'primary_territory_id' => 'geo_locations'];
/** @return array{table:string,fields:list<string>} */
public function definition(string $kind): array
{
abort_unless(isset(self::SUBJECTS[$kind]), 404);
return self::SUBJECTS[$kind];
}
public function available(): bool
{
return (bool) config('editorial_workflow.structural_subjects') && app(PortalEditorialWorkflow::class)->available() && Schema::hasColumn('portal_editorial_editions', 'subject_kind');
}
public function writes(string $kind, string $key): bool
{
$this->definition($kind);
if (config('editorial_workflow.structural_subjects')) {
abort_unless($this->available(), 503, 'Estrutura editorial dos registos ainda não instalada.');
return true;
}
abort_if(Schema::hasColumn('portal_editorial_editions', 'subject_kind') && DB::table('portal_editorial_editions')->where('subject_kind', $kind)->where('subject_key', $key)->exists(), 503, 'Reactive o circuito para conservar as versões deste registo.');
return false;
}
public function query(string $kind, string $key): Builder
{
return DB::table($this->definition($kind)['table'])->where('id', $key);
}
public function editUrl(string $kind, \stdClass $record): string
{
return match ($kind) {
'projects', 'clusters', 'corridors' => '/management/'.$kind.'/'.$record->id.'/edit',
'value-chains', 'investment-gaps', 'cluster-project' => '/management/clusters/'.$record->cluster_id.'/edit',
'value-chain-links' => '/management/clusters/'.DB::table('value_chains')->where('id', $record->from_value_chain_id)->value('cluster_id').'/edit',
'corridor-project', 'corridor-cluster' => '/management/corridors/'.$record->corridor_id.'/edit',
'zone-relations' => '/management/investment/zones/'.$record->zone_id,
default => abort(404),
};
}
public function working(string $kind, \stdClass $record): \stdClass
{
if (! $this->available()) {
return $record;
}
$workflow = app(PortalEditorialWorkflow::class);
$edition = $workflow->latestSubject($kind, $record->id);
$copy = clone $record;
if ($edition && $edition->published_at === null && $edition->state !== 'archived') {
foreach ($workflow->payload($edition)['resource']['_subject']['fields'] as $field => $value) {
$copy->{$field} = $value;
}
}
$copy->editorial_version = (int) ($edition->revision_no ?? 1);
$copy->editorial_url = '/management/editorial/subjects/'.$kind.'/'.$record->id;
return $copy;
}
/** @param array<string,mixed> $fields */
public function stageModel(User $actor, string $kind, Model $record, array $fields, int $expected): \stdClass
{
return DB::transaction(function () use ($actor, $kind, $record, $fields, $expected) {
$new = ! $record->exists;
if ($new) {
$private = in_array($kind, ['value-chains', 'value-chain-links', 'investment-gaps'], true) ? ['deleted_at' => now()] : [];
PortalAudit::save($record, [...$fields, ...$private], $actor->id);
}
$allowed = $this->definition($kind)['fields'];
return app(PortalEditorialWorkflow::class)->saveSubject($actor, $kind, (string) $record->getKey(), $expected, Arr::only($fields, $allowed), $new);
});
}
/** @return array<string,string> */
public function labels(): array
{
return ['code' => 'Código', 'name' => 'Designação', 'status' => 'Estado', 'description' => 'Descrição', 'sector' => 'Sector', 'budget_amount' => 'Orçamento', 'budget_currency' => 'Moeda', 'start_date' => 'Data de início', 'end_date' => 'Data de conclusão',
'primary_territory_id' => 'Localização principal', 'cluster_id' => 'Cluster', 'project_id' => 'Projecto', 'corridor_id' => 'Corredor', 'zone_id' => 'Zona', 'value_chain_id' => 'Etapa da cadeia', 'from_value_chain_id' => 'Etapa de origem', 'to_value_chain_id' => 'Etapa de destino',
'role' => 'Papel', 'association_type' => 'Tipo de associação', 'source_reference' => 'Fonte', 'evidence_reference' => 'Evidência', 'valid_from' => 'Válido desde', 'valid_until' => 'Válido até', 'stage' => 'Fase da cadeia', 'link_type' => 'Tipo de ligação',
'title' => 'Título', 'location' => 'Localização', 'required_capacity' => 'Capacidade necessária', 'rationale' => 'Fundamento', 'call_to_action' => 'Acção proposta', 'justification' => 'Justificação', 'legal_source_id' => 'Fonte jurídica', 'priority' => 'Prioridade', 'is_primary' => 'Relação principal'];
}
/** @return array<string,list<string>> */
private function enums(string $kind): array
{
return ['status' => match ($kind) {
'projects' => ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'], 'clusters' => ['planned', 'active', 'inactive'], 'corridors' => ['proposed', 'active', 'closed'], 'investment-gaps' => ['identified', 'in_review', 'resolved', 'withdrawn'], default => []
},
'stage' => ['input', 'production', 'processing', 'logistics', 'market'], 'link_type' => ['supplies', 'processes', 'distributes', 'supports'],
'association_type' => ['official', 'influence', 'proximity', 'potential', 'historical'], 'role' => $kind === 'zone-relations' ? ['primary', 'beneficiary', 'served', 'enabled', 'dependent', 'related', 'potential', 'historical'] : ['anchor', 'complementary', 'supporting']];
}
/** @return list<array<string,mixed>> */
public function form(string $kind): array
{
$labels = $this->labels();
$enums = $this->enums($kind);
$result = [];
foreach ($this->definition($kind)['fields'] as $field) {
if ($field === 'territory_version') {
continue;
}
$type = isset(self::PARENTS[$field]) || $field === 'legal_source_id' ? 'reference' : (isset($enums[$field]) ? 'enum' : (in_array($field, ['valid_from', 'valid_until', 'start_date', 'end_date'], true) ? 'date' : ($field === 'is_primary' ? 'boolean' : 'text')));
$result[] = ['name' => $field, 'label' => $labels[$field] ?? $field, 'type' => $type, 'options' => $enums[$field] ?? []];
}
return $result;
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function validate(string $kind, array $input): array
{
$allowed = array_values(array_diff($this->definition($kind)['fields'], ['territory_version']));
abort_unless(array_diff(array_keys($input), $allowed) === [], 422, 'Campos fora do âmbito da proposta.');
$enums = $this->enums($kind);
$rules = [];
foreach ($allowed as $field) {
$rules[$field] = ['sometimes', 'nullable', 'string', 'max:10000'];
if (isset(self::PARENTS[$field]) || $field === 'legal_source_id') {
$table = self::PARENTS[$field] ?? 'investment_legal_sources';
$rules[$field] = ['sometimes', 'nullable', 'uuid', 'exists:'.$table.',id'];
} elseif (isset($enums[$field])) {
$rules[$field] = ['sometimes', 'required', Rule::in($enums[$field])];
} elseif (in_array($field, ['valid_from', 'valid_until', 'start_date', 'end_date'], true)) {
$rules[$field] = ['sometimes', 'nullable', 'date_format:Y-m-d'];
}
}
foreach (['name', 'code', 'title', 'sector'] as $field) {
if (in_array($field, $allowed, true)) {
$rules[$field] = ['sometimes', 'required', 'string', 'max:'.($field === 'code' ? '100' : '200')];
}
}
foreach (['source_reference' => 2000, 'evidence_reference' => 2000, 'justification' => 3000, 'location' => 1000, 'required_capacity' => 2000, 'rationale' => 5000, 'call_to_action' => 2000] as $field => $limit) {
if (in_array($field, $allowed, true)) {
$rules[$field] = ['sometimes', 'nullable', 'string', 'max:'.$limit];
}
}
$requiredReferences = match ($kind) {
'cluster-project' => ['cluster_id', 'project_id'], 'corridor-project' => ['corridor_id', 'project_id'], 'corridor-cluster' => ['corridor_id', 'cluster_id'],
'value-chains' => ['cluster_id'], 'value-chain-links' => ['from_value_chain_id', 'to_value_chain_id'], 'investment-gaps' => ['cluster_id', 'value_chain_id'], 'zone-relations' => ['zone_id'], default => [],
};
foreach ($requiredReferences as $field) {
$rules[$field] = ['sometimes', 'required', 'uuid', 'exists:'.self::PARENTS[$field].',id'];
}
if (! empty($input['valid_until']) && ! empty($input['valid_from'])) {
$rules['valid_until'][] = 'after_or_equal:valid_from';
}
if (! empty($input['end_date']) && ! empty($input['start_date'])) {
$rules['end_date'][] = 'after_or_equal:start_date';
}
if (in_array('budget_amount', $allowed, true)) {
$rules['budget_amount'] = ['sometimes', 'nullable', 'required_with:budget_currency', 'regex:/^\d{1,18}(\.\d{1,2})?$/'];
$rules['budget_currency'] = ['sometimes', 'nullable', 'required_with:budget_amount', 'regex:/^[A-Z]{3}$/'];
}
if (in_array('priority', $allowed, true)) {
$rules['priority'] = ['sometimes', 'integer', 'between:0,100'];
$rules['is_primary'] = ['sometimes', 'boolean'];
}
if ($kind === 'zone-relations' && count(array_filter(Arr::only($input, ['cluster_id', 'corridor_id', 'project_id']))) > 1) {
throw ValidationException::withMessages(['project_id' => 'Seleccione apenas um projecto, cluster ou corredor para a relação.']);
}
return validator($input, $rules)->validate();
}
/** @return list<array{id:string,name:string}> */
public function choices(string $kind, string $field, ?string $query = null): array
{
abort_unless(in_array($field, $this->definition($kind)['fields'], true), 404);
$table = self::PARENTS[$field] ?? ($field === 'legal_source_id' ? 'investment_legal_sources' : abort(404));
$label = $table === 'investment_legal_sources' ? 'title' : 'name';
$builder = DB::table($table);
if ($table === 'geo_locations') {
$builder->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])->whereIn('data_classification', ['public', 'internal']);
}
if (Schema::hasColumn($table, 'deleted_at')) {
$builder->whereNull('deleted_at');
}
if ($query) {
$builder->where($label, 'ilike', '%'.addcslashes($query, '%_\\').'%');
}
return array_values($builder->orderBy($label)->orderBy('id')->limit(100)->get(['id', $label.' as name'])->map(fn ($row) => ['id' => (string) $row->id, 'name' => (string) $row->name])->all());
}
public function authorize(User $actor, string $kind, string $key, bool $write = false): void
{
abort_unless($this->available(), 503);
abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
abort_unless($this->query($kind, $key)->exists(), 404);
if ($write) {
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
}
}
public function independent(string $kind): bool
{
return in_array($kind, ['cluster-project', 'corridor-project', 'corridor-cluster', 'zone-relations'], true)
|| in_array($kind, config('editorial_workflow.independent_approval.kinds', []), true);
}
/** @param array<string,mixed> $fields
* @return array<string,mixed>
*/
public function snapshot(string $kind, string $key, array $fields): array
{
$definition = $this->definition($kind);
$record = $this->query($kind, $key)->first();
abort_unless($record !== null, 404);
abort_unless(array_diff(array_keys($fields), $definition['fields']) === [], 422, 'Campos fora do âmbito do registo.');
$context = ['record' => Arr::except(get_object_vars($record), ['created_at', 'updated_at']), 'parents' => []];
if (! empty($fields['primary_territory_id'])) {
$path = app(InvestmentGeography::class)->path($fields['primary_territory_id'], true);
abort_unless($path !== [] && count($path) <= 4 && $path[0]->location_type === 'province', 422, 'Seleccione uma localização na hierarquia existente.');
}
foreach (self::PARENTS as $field => $table) {
$id = $fields[$field] ?? $record->{$field} ?? null;
if (! $id) {
continue;
}
$parent = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first();
abort_unless($parent !== null, 422, 'Contexto relacionado indisponível.');
$context['parents'][$field] = Arr::except(get_object_vars($parent), ['created_at', 'updated_at']);
}
if (in_array($kind, ['projects', 'clusters', 'corridors'], true)) {
$foreign = ['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'][$kind];
$context['portfolio'] = DB::table('investment_dossiers')->where($foreign, $key)->pluck('id')->all();
abort_unless($context['portfolio'] === [], 409, 'Este registo tem ficha associada. Continue na revisão da carteira.');
}
if ($source = $fields['legal_source_id'] ?? $record->legal_source_id ?? null) {
$sourceRecord = DB::table('investment_legal_sources')->where('id', $source)->first();
abort_unless($sourceRecord !== null, 422, 'Fonte jurídica indisponível.');
$context['legal_source'] = get_object_vars($sourceRecord);
}
return ['fields' => $fields, 'context' => $context];
}
/** @param array<string,mixed> $fields */
public function lock(string $kind, string $key, array $fields = []): \stdClass
{
$record = $this->query($kind, $key)->first();
abort_unless($record !== null, 404);
$parents = [];
foreach (self::PARENTS as $field => $table) {
$id = $fields[$field] ?? $record->{$field} ?? null;
if ($id) {
$parents[$table][] = $id;
}
}
ksort($parents);
foreach ($parents as $table => $ids) {
DB::table($table)->whereIn('id', array_unique($ids))->orderBy('id')->lockForUpdate()->get();
}
return $this->query($kind, $key)->lockForUpdate()->firstOrFail();
}
/** @param array<string,mixed> $payload */
public function publish(User $actor, \stdClass $record, \stdClass $edition, array $payload): void
{
$subject = $payload['resource']['_subject'];
$kind = $edition->subject_kind;
$fields = $subject['fields'];
$this->lock($kind, $edition->subject_key, $fields);
if ($subject['withdraw'] ?? false) {
$this->archive($actor, $record, $edition);
return;
}
$pair = match ($kind) {
'cluster-project' => ['cluster_id', 'project_id'], 'corridor-project' => ['corridor_id', 'project_id'], 'corridor-cluster' => ['corridor_id', 'cluster_id'],
'value-chain-links' => ['from_value_chain_id', 'to_value_chain_id', 'link_type'],
'zone-relations' => ['zone_id', 'cluster_id', 'corridor_id', 'project_id'], default => [],
};
if ($pair !== []) {
abort_if($kind === 'zone-relations' && count(array_filter(Arr::only($fields, ['cluster_id', 'corridor_id', 'project_id']))) !== 1, 422, 'A relação exige um projecto, cluster ou corredor.');
$duplicates = DB::table($this->definition($kind)['table'])->where('id', '<>', $record->id);
foreach ($pair as $field) {
$duplicates->where($field, $fields[$field] ?? null);
}
if (property_exists($record, 'deleted_at')) {
$duplicates->whereNull('deleted_at');
}
abort_if($duplicates->exists(), 409, 'Esta relação já existe. Reveja a proposta sem duplicar o registo.');
}
if (in_array($kind, ['projects', 'clusters', 'corridors', 'value-chains'], true) && isset($fields['code'])) {
$query = DB::table($this->definition($kind)['table'])->where('code', $fields['code'])->where('id', '<>', $record->id)->whereNull('deleted_at');
if ($kind === 'value-chains') {
$query->where('cluster_id', $fields['cluster_id'] ?? $record->cluster_id);
}
abort_if($query->exists(), 409, 'Código entretanto utilizado. Reveja a proposta.');
}
if (in_array($kind, ['cluster-project', 'corridor-project', 'corridor-cluster', 'zone-relations'], true)) {
foreach (['source_reference', 'valid_from'] as $required) {
abort_unless(! empty($fields[$required]), 422, 'Fonte e vigência são necessárias.');
}
abort_if($kind === 'zone-relations' && ($fields['association_type'] ?? '') === 'official' && empty($fields['legal_source_id']), 422, 'A associação oficial exige a fonte jurídica prevista.');
if (! empty($fields['legal_source_id'])) {
$source = DB::table('investment_legal_sources')->where('id', $fields['legal_source_id'])->lockForUpdate()->first();
abort_unless($source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString()
&& (! $source->valid_until || $source->valid_until >= today()->toDateString()), 422, 'Fonte jurídica fora de vigência.');
}
$decision = json_decode($edition->approval, true, 512, JSON_THROW_ON_ERROR);
$fields += ['validation_status' => 'validated', 'validator_id' => $decision['actor_id'], 'editor_id' => $edition->author_id];
if (property_exists($record, 'data_classification')) {
$fields['data_classification'] = 'public';
}
}
if ($kind === 'value-chain-links') {
$chains = DB::table('value_chains')->whereIn('id', [$fields['from_value_chain_id'], $fields['to_value_chain_id']])->whereNull('deleted_at')->get(['id', 'cluster_id']);
abort_unless($chains->count() === 2 && $chains->pluck('cluster_id')->unique()->count() === 1, 422, 'As etapas devem ser distintas e pertencer ao mesmo cluster.');
}
if ($kind === 'investment-gaps') {
abort_unless(DB::table('value_chains')->where('id', $fields['value_chain_id'])->where('cluster_id', $fields['cluster_id'])->whereNull('deleted_at')->exists(), 422, 'Etapa fora do cluster da lacuna.');
}
if ($subject['new_record'] ?? false) {
$fields['deleted_at'] = null;
}
if (property_exists($record, 'data_classification')) {
$fields['data_classification'] = 'public';
}
if (property_exists($record, 'version_no')) {
$fields['version_no'] = (int) $record->version_no + 1;
}
$fields['updated_at'] = now();
$this->query($kind, $record->id)->update($fields);
$this->audit($actor, $edition, 'published', $record);
}
public function archive(User $actor, \stdClass $record, \stdClass $edition): void
{
$fields = property_exists($record, 'validation_status') ? ['validation_status' => 'revoked']
: (in_array($edition->subject_kind, ['projects', 'clusters', 'corridors'], true) ? ['data_classification' => 'internal'] : ['deleted_at' => now()]);
if (property_exists($record, 'version_no')) {
$fields['version_no'] = (int) $record->version_no + 1;
}
$this->query($edition->subject_kind, $record->id)->update([...$fields, 'updated_at' => now()]);
$this->audit($actor, $edition, 'archived', $record);
}
private function audit(User $actor, \stdClass $edition, string $action, \stdClass $before): void
{
$after = $this->query($edition->subject_kind, $edition->subject_key)->firstOrFail();
$changed = [];
foreach (get_object_vars($after) as $field => $value) {
if (($before->{$field} ?? null) !== $value) {
$changed[] = $field;
}
}
DB::table('audit_events')->insert(['source_system' => 'editorial-structural-subjects', 'entity_type' => $this->definition($edition->subject_kind)['table'],
'entity_id' => $edition->subject_key, 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted',
'before_data' => json_encode(Arr::only(get_object_vars($before), ['id', 'version_no', 'validation_status', 'deleted_at', 'data_classification']), JSON_THROW_ON_ERROR),
'after_data' => json_encode(['edition_id' => $edition->id, 'hash' => $edition->content_hash, 'record_id' => $edition->subject_key, 'changed_fields' => $changed], JSON_THROW_ON_ERROR)]);
}
}
+153 -20
View File
@@ -24,6 +24,93 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
*/
final class PortalEditorialWorkflow
{
public function legalReviewer(User $actor): bool
{
return $actor->hasVerifiedEmail() && (in_array($actor->id, config('editorial_workflow.legal_reviewers', []), true)
|| in_array(strtolower($actor->email), config('editorial_workflow.legal_reviewer_emails', []), true));
}
public function latestSubject(string $kind, string $key): ?\stdClass
{
return DB::table('portal_editorial_editions')->where('subject_kind', $kind)->where('subject_key', $key)->orderByDesc('revision_no')->first();
}
public function subjectVersion(string $kind, string $key): int
{
return (int) ($this->latestSubject($kind, $key)->revision_no ?? 1);
}
/** @param array<string,mixed> $fields
* @param list<int> $contributors
* @return EditorialPayload
*/
private function subjectSnapshot(string $kind, string $key, array $fields, array $contributors, bool $newRecord, bool $independent = false, bool $withdraw = false): array
{
$adapter = app(PortalEditorialSubjects::class);
$snapshot = $adapter->snapshot($kind, $key, $fields);
return ['resource' => ['title' => (string) ($fields['name'] ?? $fields['title'] ?? $kind), '_subject' => ['kind' => $kind, 'key' => $key, 'fields' => $fields, 'new_record' => $newRecord, 'withdraw' => $withdraw]],
'links' => [['target_type' => $adapter->definition($kind)['table'], 'target_key' => $key, 'context' => $snapshot['context']]],
'files' => [], 'document_changes' => [], 'contributors' => $contributors, 'policy' => ['independent_required' => $independent || $adapter->independent($kind)]];
}
/** @param array<string,mixed> $changes */
public function saveSubject(User $actor, string $kind, string $key, int $expected, array $changes, bool $newRecord = false, ?bool $withdraw = null): \stdClass
{
$adapter = app(PortalEditorialSubjects::class);
$adapter->authorize($actor, $kind, $key, true);
return DB::transaction(function () use ($actor, $kind, $key, $expected, $changes, $newRecord, $adapter, $withdraw) {
$record = $adapter->lock($kind, $key, $changes);
$previous = $this->latestSubject($kind, $key);
abort_unless($expected === (int) ($previous->revision_no ?? ($newRecord ? 0 : 1)), 409, 'A proposta foi alterada. Conserve os campos e confira a versão actual.');
$allowed = $adapter->definition($kind)['fields'];
abort_unless(array_diff(array_keys($changes), $allowed) === [], 422, 'Campos fora do âmbito do registo.');
if (! $previous && ! $newRecord) {
$baseline = $this->subjectSnapshot($kind, $key, Arr::only(get_object_vars($record), $allowed), [], false);
$public = ($record->data_classification ?? null) === 'public' || ($record->validation_status ?? null) === 'validated';
DB::table('portal_editorial_editions')->insert(['id' => (string) Str::uuid(), 'subject_kind' => $kind, 'subject_key' => $key, 'revision_no' => 1, 'version' => 1,
'state' => $public ? 'published' : 'archived', 'payload' => json_encode($baseline, JSON_THROW_ON_ERROR), 'content_hash' => $this->hash($baseline),
'author_id' => $actor->id, 'legal_required' => false, 'published_at' => $public ? now() : null, 'created_at' => now(), 'updated_at' => now()]);
}
$working = $previous && $previous->published_at === null && $previous->state !== 'archived' ? $this->payload($previous) : null;
$fields = array_replace(Arr::only(get_object_vars($record), $allowed), $working['resource']['_subject']['fields'] ?? [], $changes);
$adapter->validate($kind, Arr::except($fields, ['territory_version']));
if (array_key_exists('primary_territory_id', $changes) && ! array_key_exists('territory_version', $changes)) {
$prior = $working['resource']['_subject']['fields'] ?? get_object_vars($record);
if (($prior['primary_territory_id'] ?? null) !== $changes['primary_territory_id']) {
$fields['territory_version'] = (int) ($prior['territory_version'] ?? 0) + 1;
}
}
$contributors = array_values(array_unique([...($working['contributors'] ?? []), $actor->id]));
$snapshot = $this->subjectSnapshot($kind, $key, $fields, $contributors, $newRecord || ($working['resource']['_subject']['new_record'] ?? false), $previous ? $this->requiresIndependent($previous) : false, $withdraw ?? ($working['resource']['_subject']['withdraw'] ?? false));
$id = (string) Str::uuid();
DB::table('portal_editorial_editions')->insert(['id' => $id, 'subject_kind' => $kind, 'subject_key' => $key, 'revision_no' => $expected + 1, 'version' => 1,
'state' => 'draft', '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)->firstOrFail();
$this->audit($actor, $edition, 'draft_saved', 'Proposta privada guardada; registo anterior preservado.');
return $edition;
});
}
public function subjectPending(User $actor): int
{
if (! app(PortalEditorialSubjects::class)->available() || ! $actor->can('manage-portal') || app(PortalProfileAccess::class)->has($actor, 'auditor')) {
return 0;
}
return $this->subjectQueue()->whereNotIn('e.state', ['published', 'archived'])->count();
}
public function subjectQueue(): Builder
{
return DB::table('portal_editorial_editions as e')->whereNotNull('e.subject_kind')
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('portal_editorial_editions as later')->whereColumn('later.subject_kind', 'e.subject_kind')
->whereColumn('later.subject_key', 'e.subject_key')->whereColumn('later.revision_no', '>', 'e.revision_no'));
}
private const STRUCTURE_FIELDS = [
'projects' => ['code', 'name', 'status', 'description', 'budget_amount', 'budget_currency', 'start_date', 'end_date', 'primary_territory_id', 'territory_version'],
'economic_clusters' => ['code', 'name', 'status', 'sector', 'primary_territory_id', 'territory_version'],
@@ -122,6 +209,12 @@ final class PortalEditorialWorkflow
if ($access->full($actor) || $access->editorial($actor)) {
return true;
}
if (app(PortalEditorialSubjects::class)->available() && $actor->can('manage-portal')) {
return true;
}
if (app(PortalEditorialNativeNotices::class)->access($actor, 'cluster-competitiveness') || app(PortalEditorialNativeNotices::class)->access($actor, 'territorial-editorial-v1') || app(PortalEditorialNativeNotices::class)->access($actor, 'investment-zones')) {
return true;
}
$navigation = app(PortalProfileAccess::class)->navigation($actor);
return ! empty($navigation['mine']) || ! empty($navigation['review']);
@@ -158,6 +251,9 @@ final class PortalEditorialWorkflow
if ((int) $notice->user_id !== $actor->id || ! $this->available() || ! $this->canEnter($actor)) {
return false;
}
if ($notice->native_event_id ?? null) {
return app(PortalEditorialNativeNotices::class)->visible($actor, $notice);
}
if ($notice->opportunity_id !== null) {
$record = Opportunity::query()->whereKey($notice->opportunity_id)->first();
@@ -168,7 +264,12 @@ final class PortalEditorialWorkflow
return false;
}
try {
if ($edition->demographic_import_id ?? null) {
if ($edition->subject_kind ?? null) {
if (! app(PortalEditorialSubjects::class)->available()) {
return false;
}
app(PortalEditorialSubjects::class)->authorize($actor, $edition->subject_kind, $edition->subject_key);
} elseif ($edition->demographic_import_id ?? null) {
if (! $this->demographyAvailable() || ! $actor->two_factor_confirmed_at || ! $actor->two_factor_secret) {
return false;
}
@@ -201,6 +302,9 @@ final class PortalEditorialWorkflow
public function requiresIndependent(\stdClass $edition): bool
{
$p = $this->payload($edition);
if ($edition->subject_kind ?? null) {
return $p['policy']['independent_required'] || app(PortalEditorialSubjects::class)->independent($edition->subject_kind);
}
if ($edition->demographic_import_id ?? null) {
return $p['policy']['independent_required'] || in_array('demography', config('editorial_workflow.independent_approval.kinds', []), true);
}
@@ -220,9 +324,18 @@ final class PortalEditorialWorkflow
if (! $this->visibleNotice($actor, $notice)) {
continue;
}
if ($notice->native_event_id ?? null) {
$result[] = app(PortalEditorialNativeNotices::class)->present($notice);
if (count($result) === 50) {
break;
}
continue;
}
$edition = $notice->edition_id ? DB::table('portal_editorial_editions')->where('id', $notice->edition_id)->first() : null;
$subjectHref = ($edition->subject_kind ?? null) ? '/management/editorial/subjects/'.$edition->subject_kind.'/'.$edition->subject_key.'?edition='.$edition->id : null;
$result[] = ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at,
'href' => $notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : (($edition->demographic_import_id ?? null) ? '/management/editorial/demography/'.$edition->demographic_import_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];
'href' => $subjectHref ?? ($notice->opportunity_id !== null ? '/minhas-oportunidades/'.$notice->opportunity_id : (($edition->demographic_import_id ?? null) ? '/management/editorial/demography/'.$edition->demographic_import_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;
}
@@ -251,7 +364,9 @@ final class PortalEditorialWorkflow
return null;
}
return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor) + $this->portfolioPending($actor) + $this->demographyPending($actor), 'unread' => $this->unreadCount($actor)];
$native = app(PortalEditorialNativeNotices::class)->pending($actor);
return ['pending' => $this->queue($actor)->whereNotIn('e.state', ['published', 'archived'])->count() + $this->opportunityPending($actor) + $this->portfolioPending($actor) + $this->demographyPending($actor) + $this->subjectPending($actor) + $native['clusters'] + $native['territories'] + $native['zones'], 'unread' => $this->unreadCount($actor)];
}
/** @param array<string,mixed> $resource
@@ -450,6 +565,11 @@ final class PortalEditorialWorkflow
*/
private function refreshPayload(\stdClass $edition, array $payload): array
{
if ($edition->subject_kind ?? null) {
$subject = $payload['resource']['_subject'];
return $this->subjectSnapshot($edition->subject_kind, $edition->subject_key, $subject['fields'], $payload['contributors'], $subject['new_record'] ?? false, $payload['policy']['independent_required'], $subject['withdraw'] ?? false);
}
if ($edition->demographic_import_id ?? null) {
return $this->demographySnapshot($edition->demographic_import_id, $payload['contributors'], $payload['policy']);
}
@@ -697,10 +817,12 @@ final class PortalEditorialWorkflow
}
/** @param array<string,mixed> $input */
public function transition(User $actor, int|string $resource, string $id, array $input, bool $portfolio = false, bool $demography = false): \stdClass
public function transition(User $actor, int|string $resource, string $id, array $input, bool $portfolio = false, bool $demography = false, ?string $subject = null): \stdClass
{
abort_if($portfolio && $demography, 422);
if ($demography) {
abort_if(count(array_filter([$portfolio, $demography, $subject !== null])) > 1, 422);
if ($subject !== null) {
app(PortalEditorialSubjects::class)->authorize($actor, $subject, (string) $resource, true);
} elseif ($demography) {
$this->authorizeDemography($actor, (string) $resource, true);
} elseif ($portfolio) {
$this->authorizePortfolio($actor, (string) $resource, true);
@@ -713,18 +835,25 @@ final class PortalEditorialWorkflow
}
$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, $portfolio, $demography) {
$r = DB::table($demography ? 'demographic_imports' : ($portfolio ? 'investment_dossiers' : 'portal_resources'))->where('id', $resource)->lockForUpdate()->first();
$e = DB::table('portal_editorial_editions')->where('id', $id)->where($demography ? 'demographic_import_id' : ($portfolio ? 'dossier_id' : 'resource_id'), $resource)->lockForUpdate()->first();
return DB::transaction(function () use ($actor, $resource, $id, $data, $portfolio, $demography, $subject) {
if ($subject !== null) {
$candidate = DB::table('portal_editorial_editions')->where('subject_kind', $subject)->where('subject_key', $resource)->where('id', $id)->first();
abort_unless($candidate !== null, 404);
$r = app(PortalEditorialSubjects::class)->lock($subject, (string) $resource, $this->payload($candidate)['resource']['_subject']['fields']);
$e = DB::table('portal_editorial_editions')->where('id', $id)->lockForUpdate()->first();
} else {
$r = DB::table($demography ? 'demographic_imports' : ($portfolio ? 'investment_dossiers' : 'portal_resources'))->where('id', $resource)->lockForUpdate()->first();
$e = DB::table('portal_editorial_editions')->where('id', $id)->where($demography ? 'demographic_import_id' : ($portfolio ? 'dossier_id' : 'resource_id'), $resource)->lockForUpdate()->first();
}
abort_unless($r && $e, 404);
$latest = $demography ? $this->latestDemography((string) $resource) : ($portfolio ? $this->latestPortfolio((string) $resource) : $this->latest((int) $resource));
$latest = $subject !== null ? $this->latestSubject($subject, (string) $resource) : ($demography ? $this->latestDemography((string) $resource) : ($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.');
abort_unless($subject !== null ? $actor->can('manage-portal') : 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);
abort_unless((int) $e->author_id === $actor->id || ($subject !== null ? $actor->can('manage-portal') : 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'],
@@ -737,7 +866,7 @@ final class PortalEditorialWorkflow
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) || ($portfolio && $action === 'publish')) && $this->requiresIndependent($e)) {
if ((in_array($action, ['approve', 'legal_validate'], true) || (($portfolio || $subject !== null) && $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)) {
@@ -746,7 +875,7 @@ final class PortalEditorialWorkflow
}
}
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.');
abort_unless($this->legalReviewer($actor), 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') {
@@ -760,7 +889,9 @@ final class PortalEditorialWorkflow
if ($e->legal_required) {
$this->assertDecision($e, $e->legal_validation, 'Validação jurídica desta edição necessária.', true);
}
if ($demography) {
if ($subject !== null) {
app(PortalEditorialSubjects::class)->publish($actor, $r, $e, $payload);
} elseif ($demography) {
$this->publishDemography($actor, $r, $e, $payload, $data['reason']);
} elseif ($portfolio) {
$this->publishPortfolio($actor, $r, $e, $payload);
@@ -771,12 +902,14 @@ final class PortalEditorialWorkflow
}
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_unless(($subject !== null ? $responsible->can('manage-portal') : 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') {
if ($demography) {
if ($subject !== null) {
app(PortalEditorialSubjects::class)->archive($actor, $r, $e);
} elseif ($demography) {
$this->demographyStatus($actor, $r, 'disabled', $data['reason'], $e->id);
} elseif ($portfolio) {
DB::table('investment_dossiers')->where('id', $resource)->update(['publication_status' => 'draft', 'version_no' => (int) $r->version_no + 1, 'updated_at' => now()]);
@@ -806,10 +939,10 @@ final class PortalEditorialWorkflow
$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.');
abort_unless($actor && $actor->hasVerifiedEmail() && (($e->subject_kind ?? null) ? $actor->can('manage-portal') : 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.');
abort_unless($this->legalReviewer($actor), 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.');
@@ -850,7 +983,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, 'dossier_id' => $e->dossier_id ?? null, 'demographic_import_id' => $e->demographic_import_id ?? null, '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, 'demographic_import_id' => $e->demographic_import_id ?? null, 'subject_kind' => $e->subject_kind ?? null, 'subject_key' => $e->subject_key ?? 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]);
}
}
+3 -1
View File
@@ -234,8 +234,10 @@ final class TerritorialRegistry
$version = (int) $state['location']['version_no'];
$revision = $this->snapshot($id, $version, $actor, $reason, $state);
$checksum = DB::table('geo_territory_revisions')->where('id', $revision)->value('checksum');
DB::table('audit_events')->insert(['source_system' => self::EDITORIAL_SOURCE, 'entity_type' => 'geo_locations', 'entity_id' => $id, 'actor_user_id' => $actor->id,
$eventId = (string) Str::uuid();
DB::table('audit_events')->insert(['id' => $eventId, 'source_system' => self::EDITORIAL_SOURCE, 'entity_type' => 'geo_locations', 'entity_id' => $id, 'actor_user_id' => $actor->id,
'action' => $action, 'data_classification' => 'restricted', 'after_data' => json_encode(['version_no' => $version, 'revision_id' => $revision, 'checksum' => trim($checksum), 'state' => $state['profile']['status']], JSON_THROW_ON_ERROR)]);
app(PortalEditorialNativeNotices::class)->emit($actor, $eventId, $state['_editorial']['contributors'] ?? []);
}
/** @param array<string,mixed> $data */
+5
View File
@@ -1,5 +1,7 @@
<?php
$legalReviewerEmails = env('PORTAL_EDITORIAL_LEGAL_REVIEWERS', '');
return [
// Development/QA opt-in. Installation and activation in an operational target are separate decisions.
'enabled' => env('PORTAL_EDITORIAL_WORKFLOW_ENABLED', false),
@@ -7,8 +9,11 @@ return [
'portfolio' => env('PORTAL_EDITORIAL_PORTFOLIO_ENABLED', false),
'demography' => env('PORTAL_EDITORIAL_DEMOGRAPHY_ENABLED', false),
'territorial_versions' => env('PORTAL_EDITORIAL_TERRITORIAL_ENABLED', false),
'structural_subjects' => env('PORTAL_EDITORIAL_STRUCTURES_ENABLED', false),
'zone_review' => env('PORTAL_EDITORIAL_ZONE_REVIEW_ENABLED', false),
// Legal designation is explicit; management authority alone is not a legal designation.
'legal_reviewers' => [],
'legal_reviewer_emails' => is_string($legalReviewerEmails) ? array_values(array_filter(array_map(static fn (string $email): string => strtolower(trim($email)), explode(',', $legalReviewerEmails)))) : [],
// Required separation is a policy, distinct from the legacy fact "independent_review".
'independent_approval' => ['resources' => [], 'kinds' => []],
];
@@ -0,0 +1,35 @@
<?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 $table): void {
$table->foreignUuid('native_event_id')->nullable()->constrained('audit_events')->restrictOnDelete();
$table->unique(['user_id', 'native_event_id', 'action'], 'editorial_native_notice_unique');
});
DB::statement('ALTER TABLE portal_editorial_notifications DROP CONSTRAINT portal_editorial_notice_subject, ADD CONSTRAINT portal_editorial_notice_subject CHECK (num_nonnulls(edition_id,opportunity_id,native_event_id)=1 AND (opportunity_id IS NULL OR opportunity_version IS NOT NULL))');
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_notifications')->whereNotNull('native_event_id')->exists()) {
throw new RuntimeException('Preserve native editorial notifications and history.');
}
DB::statement('ALTER TABLE portal_editorial_notifications DROP CONSTRAINT portal_editorial_notice_subject, 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))');
Schema::table('portal_editorial_notifications', function (Blueprint $table): void {
$table->dropUnique('editorial_native_notice_unique');
$table->dropForeign(['native_event_id']);
$table->dropColumn('native_event_id');
});
}
};
@@ -0,0 +1,35 @@
<?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 $table): void {
$table->string('subject_kind', 40)->nullable();
$table->uuid('subject_key')->nullable();
$table->unique(['subject_kind', 'subject_key', 'revision_no'], 'editorial_subject_revision_unique');
});
DB::statement("ALTER TABLE portal_editorial_editions DROP CONSTRAINT portal_editorial_edition_subject, ADD CONSTRAINT portal_editorial_edition_subject CHECK (num_nonnulls(resource_id,dossier_id,demographic_import_id,subject_key)=1 AND ((subject_kind IS NULL)=(subject_key IS NULL)) AND (subject_kind IS NULL OR subject_kind IN ('projects','clusters','corridors','cluster-project','corridor-project','corridor-cluster','value-chains','value-chain-links','investment-gaps','zone-relations')))");
}
public function down(): void
{
if (! IsolatedQaDatabase::allowsSchemaChanges() || DB::table('portal_editorial_editions')->whereNotNull('subject_kind')->exists()) {
throw new RuntimeException('Preserve structural editions and decisions.');
}
DB::statement('ALTER TABLE portal_editorial_editions DROP CONSTRAINT portal_editorial_edition_subject, ADD CONSTRAINT portal_editorial_edition_subject CHECK (num_nonnulls(resource_id,dossier_id,demographic_import_id)=1)');
Schema::table('portal_editorial_editions', function (Blueprint $table): void {
$table->dropUnique('editorial_subject_revision_unique');
$table->dropColumn(['subject_kind', 'subject_key']);
});
}
};
@@ -1,3 +1,20 @@
## 14/11/2026 — complemento único do Dashboard Colaborativo em QA
**Estado:** a homologação mantém a entrega confirmada `d5c960b6`. Este complemento está implementado e verificado em QA, sem instalação. A nomeação do validador jurídico foi recebida da Tempo; não permanece como decisão organizacional em falta. A configuração nominal será aplicada apenas na entrega conjunta, conservando os restantes âmbitos da conta.
- **Fila e notificações:** Competitividade, perfis territoriais e revisão de zonas reutilizam a caixa editorial comum. As notificações identificam o registo e a versão, não concedem acesso; a leitura volta a conferir o âmbito actual. Contadores excluem publicações/arquivos e não adoptam automaticamente fontes ou zonas antigas.
- **Estruturas e cadeias:** projectos, clusters e corredores sem ficha, relações entre eles e com zonas, etapas, ligações e lacunas usam as mesmas edições, decisões e auditoria. Não se cria outro dossier. Alterar conserva a publicação anterior; novos elementos de cadeia permanecem privados. Retirar exige proposta e publicação expressas. Conflitos de versão, relações repetidas, contexto territorial alterado, datas ou montantes incompatíveis não sobrescrevem os registos.
- **Outras estatísticas existentes:** observações e indicadores documentais das zonas reutilizam o rascunho encriptado, revisões e projecção pública nativos. Conservar valor decimal, unidade, período, âmbito e fonte; revisão → aprovação → publicação são operações distintas. Alterações no conteúdo, contexto, anexos ou autoridade invalidam a decisão. Consultar uma revisão recupera a descrição e observações exactas sem alterar a edição actual. Agregados privados de origem/análise proactiva continuam derivados dos dados autorizados; não se inventa um conjunto editorial público nem totais em falta.
- **Legislação:** `PORTAL_EDITORIAL_LEGAL_REVIEWERS` permite a designação nominal recebida, com correio verificado e âmbito documental/MFA existentes. Não concede gestão geral, não aprova versões anteriores e não atribui autoridade jurídica sobre zonas ou cálculos.
**Utilização após instalação:** abrir `/management/editorial`; as filas existentes continuam disponíveis. Aceder a **Estruturas e relações** em `/management/editorial/subjects`, escolher o registo e usar **Alterar proposta**; guardar → submeter → iniciar revisão → aprovar → publicar. O editor nativo continua a ser a entrada para criar os registos. Nas zonas, abrir `/management/investment/zones/{id}`, guardar a preparação, usar **Revisão da zona e dos indicadores** e publicar separadamente. **Histórico editorial → Conferir versão** é consulta privada, sem restauração automática. Todos os endpoints, escolhas de catálogo, pré-visualizações e decisões conferem permissões no servidor.
**Verificação do delta:** `outputs/editorial-completion-20261114` contém os recibos. Testes focados finais 28/361, regressão nativa afectada 35/540, frontend 13, tipos e compilação aprovados. A análise estática dos ficheiros alterados passou após correcções localizadas. Duas migrações QA adicionam três colunas e respectivas restrições/índices, sem backfill de conteúdos ou permissões. SQL nominal ensaiado numa cópia isolada no cluster 55441, comparado com o QA original; repetição recusada sem alterações. Operações no destino activo: zero neste complemento.
**Entrega única e recuperação:** preservar os pacotes anteriores. Instalar apenas a candidata conjunta após autorização concreta, pelo executor Git existente, com backup/restauro conferido, contrato nominal das duas migrações e activação de `PORTAL_EDITORIAL_STRUCTURES_ENABLED` e `PORTAL_EDITORIAL_ZONE_REVIEW_ENABLED`. Configurar o validador nominal protegido na mesma passagem. Não executar migrações indiscriminadamente, republicar fontes ou alterar permissões gerais. Após escritas nas novas versões, conservar colunas e histórico; usar reparação compatível em vez de remover as migrações. Restauro integral apenas antes de novas operações, com conferência de dados.
**Conferência visual:** em 14/11/2026 a enumeração identificou Edge/Firefox como aplicações, mas devolveu `Browsers: Error: The browser access host is unavailable`, sem abas nem URL verificáveis. Não se repetiram reinícios nem se alteraram portas, permissões ou segurança. Permanece por conferir, na candidata QA autenticada, fila → proposta → decisão → publicação, notificações/histórico e janela estreita. Não declarar esta conferência aprovada nem a instalação deste complemento concluída. O fecho deste dashboard não declara concluídas as restantes funcionalidades do portal.
## 14/11/2026 — pacote editorial instalado e activo na homologação
Instalação conjunta expressamente autorizada pelo proprietário, incluindo as quatro alterações aditivas de estrutura. Revisão efectivamente servida e conferida em `https://teste-investidor.tempo.co.mz/build/revision.json`: **d5c960b62f97f04fb41acf71197ad2e7abbdf099**. Integra a candidata editorial `b3d4b7ab` e preserva as alterações fiscais/mapa aceites até `e4735424`, mediante duas integrações sem conflitos de código editorial. O Git/webhook/executor existentes fizeram a compilação e a substituição atómica; nenhuma edição manual da release servida.
File diff suppressed because one or more lines are too long
+239 -13
View File
@@ -89,6 +89,15 @@ const labels: Record<string, string> = {
const label = (value?: string | null) =>
value ? (labels[value] ?? value) : 'Não informado';
type Zone = {
editorial_enabled?: boolean;
editorial_workflow?: {
state: string;
fresh: boolean;
content_version: number;
hash: string;
can_approve: boolean;
independent_required: boolean;
} | null;
operational_details?: ParkDetails;
operational_details_public?: ParkDetails;
details_available?: boolean;
@@ -248,6 +257,206 @@ type Props = {
prev_page_url: string | null;
} | null;
};
function ZoneHistory({
id,
revisions,
}: {
id: string;
revisions: Props['revisions'];
}) {
const [selected, setSelected] = useState<{
version_no: number;
fields: { name?: string; description?: string };
observations: {
label?: string;
value?: string;
unit?: string;
scope?: string;
period_start?: string;
period_end?: string;
source_reference?: string;
}[];
} | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function inspect(version: number) {
setLoading(true);
setError('');
setSelected(null);
try {
setSelected(
await api(
`/api/v2/investment/admin/zones/${id}/revisions/${version}`,
),
);
} catch (e) {
setError(
e instanceof Error
? e.message
: 'Não foi possível conferir esta versão.',
);
} finally {
setLoading(false);
}
}
return (
<section className={card}>
<h2 className="text-xl font-bold">Histórico editorial</h2>
{revisions.map((r) => (
<p key={r.version_no}>
Versão {r.version_no} · {r.action} · {r.created_at}{' '}
<button
type="button"
className={button}
disabled={loading}
onClick={() => inspect(r.version_no)}
>
Conferir versão {r.version_no}
</button>
</p>
))}
{error && <p role="alert">{error}</p>}
{selected && (
<div className="space-y-2 border-t pt-3">
<h3 className="font-bold">
Consulta da versão {selected.version_no}
</h3>
<p>
Esta consulta preserva a versão actual e a preparação em
curso.
</p>
<p>{selected.fields.name}</p>
<p className="whitespace-pre-line">
{selected.fields.description}
</p>
{selected.observations.map((row, index) => (
<div key={index}>
<strong>{row.label}</strong>
<p>
{row.value} {row.unit}
</p>
<p>
{row.scope} · {row.period_start} –{' '}
{row.period_end}
</p>
<p>{row.source_reference}</p>
</div>
))}
</div>
)}
</section>
);
}
function ZoneReview({
zone,
busy,
dirty,
action,
}: {
zone: Zone;
busy: boolean;
dirty: boolean;
action: (url: string, data: unknown) => Promise<void>;
}) {
const [reason, setReason] = useState('');
const workflow = zone.editorial_workflow;
const labels: Record<string, string> = {
submit: 'Submeter à revisão',
start_review: 'Iniciar revisão',
approve: 'Aprovar esta edição',
return: 'Devolver com fundamento',
reject: 'Rejeitar',
archive: 'Arquivar proposta privada',
};
const states: Record<string, string> = {
draft: 'Rascunho',
submitted: 'Submetido',
in_review: 'Em revisão',
approved: 'Aprovado',
returned: 'Devolvido',
rejected: 'Rejeitado',
archived: 'Arquivado',
};
const actions: Record<string, string[]> = {
not_started: ['submit'],
draft: ['submit', 'archive'],
submitted: ['start_review', 'archive'],
in_review: ['return', 'reject', 'approve', 'archive'],
approved: ['archive'],
returned: ['submit', 'archive'],
rejected: ['archive'],
archived: [],
};
return (
<section className={card}>
<h2 className="text-xl font-bold">
Revisão da zona e dos indicadores
</h2>
<p>
{workflow
? states[workflow.state]
: 'Prepare a revisão da edição actual.'}{' '}
A aprovação é separada da publicação. A apresentação anterior
mantém-se durante a revisão.
</p>
{workflow && (
<p>
Edição do conteúdo: {workflow.content_version} · versão de
trabalho: {zone.version_no}.
</p>
)}
{workflow && !workflow.fresh && (
<p role="alert">
O conteúdo, fonte ou anexos mudaram. Guarde uma nova
preparação e volte a conferir.
</p>
)}
{dirty && (
<p>Guarde as alterações antes de decidir sobre a edição.</p>
)}
<Field label="Fundamento da decisão">
<textarea
className={input}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</Field>
<div className="mt-3 flex flex-wrap gap-3">
{(actions[workflow?.state ?? 'not_started'] ?? [])
.filter(
(name) => name !== 'approve' || workflow?.can_approve,
)
.map((name) => (
<button
key={name}
className={button}
type="button"
disabled={
busy ||
dirty ||
reason.trim().length < 10 ||
Boolean(
workflow &&
!workflow.fresh &&
['submit', 'approve'].includes(name),
)
}
onClick={() =>
void action(
`/api/v2/investment/admin/zones/${zone.id}/editorial/${name}`,
{ version_no: zone.version_no, reason },
)
}
>
{labels[name]}
</button>
))}
</div>
</section>
);
}
export default function ZonePage(props: Props) {
return (
<Editor
@@ -340,8 +549,19 @@ function Editor({
setBusy(true);
setMessage('');
try {
await api(url, data);
router.reload();
const result = await api(url, data);
if (
result &&
typeof result === 'object' &&
typeof result.editorial_url === 'string' &&
result.editorial_url.startsWith(
'/management/editorial/subjects/',
)
) {
router.visit(result.editorial_url);
} else {
router.reload();
}
} catch (e) {
setMessage(e instanceof Error ? e.message : 'Falha na operação.');
} finally {
@@ -1342,6 +1562,14 @@ function Editor({
Guardar outra localização
</button>
</section>
{z.editorial_enabled && (
<ZoneReview
zone={z}
busy={busy}
dirty={form.isDirty}
action={action}
/>
)}
<section className={card}>
<h2 className="text-xl font-bold">
Publicação informativa
@@ -1359,6 +1587,14 @@ function Editor({
disabled={
busy ||
form.isDirty ||
(z.editorial_enabled &&
(z.editorial_workflow?.state !==
'approved' ||
!z.editorial_workflow?.fresh ||
(z.editorial_workflow
?.independent_required &&
!z.editorial_workflow
?.can_approve))) ||
(z.category === 'industrial_park' &&
!profileAvailable)
}
@@ -1518,17 +1754,7 @@ function Editor({
</div>
))}
</section>
<section className={card}>
<h2 className="text-xl font-bold">
Histórico editorial
</h2>
{revisions.map((r) => (
<p key={r.version_no}>
Versão {r.version_no} · {r.action} ·{' '}
{r.created_at}
</p>
))}
</section>
<ZoneHistory id={z.id} revisions={revisions} />
{targets && (
<ZoneRelations
id={z.id}
@@ -2,6 +2,8 @@ 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';
import SubjectForm from './editorial-subject-form';
import type { SubjectField } from './editorial-subject-form';
type Edition = {
id: string;
@@ -27,6 +29,13 @@ type Edition = {
fields: Record<string, string | number | null>;
territory_context?: { id: string; name: string }[];
};
_subject?: {
kind: string;
key: string;
fields: Record<string, string | number | boolean | null>;
new_record: boolean;
withdraw?: boolean;
};
};
files: {
id: string;
@@ -55,8 +64,16 @@ export default function EditorialEdition({
canApprove,
independentRequired,
subject = 'resource',
subjectKind,
editUrl,
subjectFields = [],
subjectInitial = {},
}: {
subject?: 'resource' | 'portfolio' | 'demography';
subject?: 'resource' | 'portfolio' | 'demography' | 'structural';
subjectKind?: string;
editUrl?: string;
subjectFields?: SubjectField[];
subjectInitial?: Record<string, string | number | boolean | null>;
resource: {
id: number | string;
title: string;
@@ -79,7 +96,10 @@ export default function EditorialEdition({
canApprove: boolean;
independentRequired: boolean;
}) {
const editionBase = `/management/editorial/${subject === 'resource' ? 'resources' : subject}/${resource.id}`;
const editionBase =
subject === 'structural'
? `/management/editorial/subjects/${subjectKind}/${resource.id}`
: `/management/editorial/${subject === 'resource' ? 'resources' : subject}/${resource.id}`;
const form = useForm({
version: edition?.version ?? 1,
action: '',
@@ -103,7 +123,9 @@ export default function EditorialEdition({
reject: 'Rejeitar',
legal_validate: 'Validar juridicamente',
approve: 'Aprovar',
publish: 'Publicar',
publish: edition?.payload.resource._subject?.withdraw
? 'Publicar retirada'
: 'Publicar',
archive: 'Arquivar',
};
return (
@@ -112,7 +134,14 @@ export default function EditorialEdition({
<Link className="underline" href="/management/editorial">
Fila e notificações
</Link>
{subject === 'demography' ? (
{subject === 'structural' ? (
<Link
className="underline"
href={editUrl ?? '/management/editorial/subjects'}
>
Alterar no contexto original
</Link>
) : subject === 'demography' ? (
<Link
className="underline"
href={`/management/demography?batch=${resource.id}`}
@@ -153,6 +182,14 @@ export default function EditorialEdition({
{resource.public_edition_no && (
<p>Versão pública: edição {resource.public_edition_no}.</p>
)}
{!edition && subject === 'structural' && (
<SubjectForm
base={editionBase}
revision={1}
fields={subjectFields}
initial={subjectInitial}
/>
)}
{edition ? (
<>
<h2 className="font-semibold">
@@ -161,20 +198,75 @@ export default function EditorialEdition({
</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.
O conteúdo, contexto ou 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{' '}
{subject === 'portfolio' ? 'contexto' : 'anexos'}:{' '}
{edition.content_hash}
{['portfolio', 'structural'].includes(subject)
? 'contexto'
: 'anexos'}
: {edition.content_hash}
</p>
<h3 className="font-semibold">Conferir conteúdo</h3>
<p>{edition.payload.resource.title}</p>
{edition.payload.resource._subject?.withdraw && (
<p role="status">
Proposta de retirada: o registo actual só será
retirado depois da publicação desta decisão.
</p>
)}
{subject === 'structural' &&
edition.payload.resource._subject && (
<>
<dl className="grid gap-2 sm:grid-cols-2">
{subjectFields.map((field) => {
const value =
edition.payload.resource._subject
?.fields[field.name];
return (
<div key={field.name}>
<dt className="font-medium">
{field.label}
</dt>
<dd className="break-words whitespace-pre-wrap">
{value === null ||
value === '' ||
value === undefined
? 'Não indicado'
: typeof value ===
'boolean'
? value
? 'Sim'
: 'Não'
: String(value)}
</dd>
</div>
);
})}
</dl>
{current && (
<SubjectForm
key={edition.id}
base={editionBase}
revision={edition.revision_no}
fields={subjectFields}
initial={
edition.payload.resource._subject
.fields
}
withdraw={
edition.payload.resource._subject
.withdraw
}
/>
)}
</>
)}
<p className="whitespace-pre-wrap">
{edition.payload.resource.description}
</p>
@@ -408,7 +500,10 @@ export default function EditorialEdition({
canApprove)) &&
(action !== 'approve' ||
canApprove) &&
(subject !== 'portfolio' ||
(![
'portfolio',
'structural',
].includes(subject) ||
action !== 'publish' ||
canApprove) &&
(edition.fresh ||
@@ -0,0 +1,290 @@
import { useEffect, useState } from 'react';
import { useForm } from '@inertiajs/react';
import { portalRequest } from '@/lib/portal-api';
import { Field, button, control } from './shared';
export type SubjectField = {
name: string;
label: string;
type: string;
options: string[];
};
type Value = string | number | boolean | null;
const names: Record<string, string> = {
draft: 'Rascunho',
planned: 'Planeado',
active: 'Activo',
suspended: 'Suspenso',
completed: 'Concluído',
cancelled: 'Cancelado',
inactive: 'Inactivo',
proposed: 'Proposto',
closed: 'Encerrado',
input: 'Insumos',
production: 'Produção',
processing: 'Transformação',
logistics: 'Logística',
market: 'Mercado',
supplies: 'Fornece',
processes: 'Transforma',
distributes: 'Distribui',
supports: 'Apoia',
official: 'Oficial',
influence: 'Influência',
proximity: 'Proximidade',
potential: 'Potencial',
historical: 'Histórica',
anchor: 'Âncora',
complementary: 'Complementar',
supporting: 'Apoio',
primary: 'Principal',
beneficiary: 'Beneficiária',
served: 'Servida',
enabled: 'Viabilizada',
dependent: 'Dependente',
related: 'Relacionada',
identified: 'Identificada',
in_review: 'Em análise',
resolved: 'Resolvida',
withdrawn: 'Retirada',
};
function Reference({
base,
field,
value,
onChange,
}: {
base: string;
field: SubjectField;
value: Value;
onChange: (value: Value) => void;
}) {
const [query, setQuery] = useState('');
const [items, setItems] = useState<{ id: string; name: string }[]>([]);
const [message, setMessage] = useState('');
useEffect(() => {
const abort = new AbortController();
const timer = setTimeout(() => {
void portalRequest<{ data: { id: string; name: string }[] }>(
`${base}/choices?${new URLSearchParams({ field: field.name, q: query })}`,
{ signal: abort.signal },
)
.then((data) => {
setItems(data.data);
setMessage('');
})
.catch((error) => {
if (!abort.signal.aborted)
setMessage(
error instanceof Error
? error.message
: 'Não foi possível consultar o catálogo.',
);
});
}, 250);
return () => {
clearTimeout(timer);
abort.abort();
};
}, [base, field.name, query]);
return (
<div className="space-y-2">
<input
className={control}
aria-label={`Pesquisar ${field.label.toLowerCase()}`}
placeholder="Pesquisar no catálogo existente"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<select
className={control}
aria-label={field.label}
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value || null)}
>
<option value="">Sem selecção</option>
{value && !items.some((item) => item.id === value) && (
<option value={String(value)}>
Selecção actual preservada
</option>
)}
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
{items.length === 100 && (
<p className="text-sm">
Refine a pesquisa para consultar outros resultados.
</p>
)}
{message && <p role="status">{message}</p>}
</div>
);
}
export default function SubjectForm({
base,
revision,
fields,
initial,
withdraw = false,
}: {
base: string;
revision: number;
fields: SubjectField[];
initial: Record<string, Value>;
withdraw?: boolean;
}) {
const [open, setOpen] = useState(false);
const form = useForm({
expected_revision: revision,
withdraw,
fields: Object.fromEntries(
fields.map((field) => [field.name, initial[field.name] ?? null]),
) as Record<string, Value>,
});
const set = (field: string, value: Value) =>
form.setData('fields', { ...form.data.fields, [field]: value });
return (
<details
className="rounded border p-4"
onToggle={(event) => setOpen(event.currentTarget.open)}
>
<summary className="cursor-pointer font-medium underline">
Alterar proposta
</summary>
{open && (
<form
className="mt-4 space-y-4"
onSubmit={(event) => {
event.preventDefault();
form.post(`${base}/drafts`, { preserveScroll: true });
}}
>
<p>
Guarde uma nova edição. As decisões anteriores mantêm-se
no histórico e não aprovam automaticamente esta
alteração.
</p>
<label className="flex gap-2">
<input
type="checkbox"
checked={form.data.withdraw}
onChange={(event) =>
form.setData('withdraw', event.target.checked)
}
/>
Propor a retirada deste registo. A situação actual
mantém-se até à publicação expressa da decisão.
</label>
<div className="grid gap-4 sm:grid-cols-2">
{fields.map((field) => (
<Field
key={field.name}
label={field.label}
error={
(form.errors as Record<string, string>)[
`fields.${field.name}`
] ??
(form.errors as Record<string, string>)[
field.name
]
}
>
{field.type === 'reference' ? (
<Reference
base={base}
field={field}
value={form.data.fields[field.name]}
onChange={(value) =>
set(field.name, value)
}
/>
) : field.type === 'enum' ? (
<select
className={control}
value={String(
form.data.fields[field.name] ?? '',
)}
onChange={(event) =>
set(field.name, event.target.value)
}
>
<option value="">Seleccionar</option>
{field.options.map((option) => (
<option key={option} value={option}>
{names[option] ?? option}
</option>
))}
</select>
) : field.type === 'boolean' ? (
<input
type="checkbox"
checked={Boolean(
form.data.fields[field.name],
)}
onChange={(event) =>
set(
field.name,
event.target.checked,
)
}
/>
) : field.type === 'date' ? (
<input
className={control}
type="date"
value={String(
form.data.fields[field.name] ?? '',
).slice(0, 10)}
onChange={(event) =>
set(
field.name,
event.target.value || null,
)
}
/>
) : (
<textarea
className={control}
rows={
field.name === 'description' ||
field.name === 'rationale'
? 4
: 2
}
value={String(
form.data.fields[field.name] ?? '',
)}
onChange={(event) =>
set(
field.name,
event.target.value || null,
)
}
/>
)}
</Field>
))}
</div>
{Object.entries(form.errors)
.filter(
([key]) =>
key === 'expected_revision' || key === 'fields',
)
.map(([key, message]) => (
<p role="alert" key={key}>
{message}
</p>
))}
<button className={button} disabled={form.processing}>
Guardar nova edição privada
</button>
</form>
)}
</details>
);
}
@@ -0,0 +1,75 @@
import { Link } from '@inertiajs/react';
import { Management } from './shared';
import { states } from './editorial-workflow';
const titles: Record<string, string> = {
projects: 'Projectos',
clusters: 'Clusters',
corridors: 'Corredores',
'cluster-project': 'Projectos associados ao cluster',
'corridor-project': 'Projectos associados ao corredor',
'corridor-cluster': 'Clusters associados ao corredor',
'value-chains': 'Etapas da cadeia de valor',
'value-chain-links': 'Ligações da cadeia de valor',
'investment-gaps': 'Lacunas de investimento',
'zone-relations': 'Relações territoriais',
};
type Item = {
id: string;
subject_kind: string;
subject_key: string;
revision_no: number;
state: string;
title: string;
};
export default function Subjects({
items,
}: {
items: {
data: Item[];
prev_page_url: string | null;
next_page_url: string | null;
};
}) {
return (
<Management title="Revisão de estruturas e relações">
<Link className="underline" href="/management/editorial">
Circuito editorial
</Link>
<p>
Prepare no formulário existente. Reveja e publique a edição
aprovada, conservando o registo anterior durante a preparação.
</p>
{!items.data.length && <p>Não existem propostas neste percurso.</p>}
<ul className="space-y-4">
{items.data.map((item) => (
<li key={item.id} className="rounded border p-4">
<p className="text-sm">
{titles[item.subject_kind] ?? item.subject_kind} ·{' '}
{states[item.state] ?? item.state} · edição{' '}
{item.revision_no}
</p>
<Link
className="font-semibold underline"
href={`/management/editorial/subjects/${item.subject_kind}/${item.subject_key}`}
>
{item.title}
</Link>
</li>
))}
</ul>
<nav aria-label="Páginas de propostas" className="flex gap-4">
{items.prev_page_url && (
<Link className="underline" href={items.prev_page_url}>
Anterior
</Link>
)}
{items.next_page_url && (
<Link className="underline" href={items.next_page_url}>
Seguinte
</Link>
)}
</nav>
</Management>
);
}
@@ -21,6 +21,18 @@ type Item = {
legal_required: boolean;
};
const eventTitles: Record<string, string> = {
submitted: 'Submissão',
pending_review: 'Submissão',
review_started: 'Início da revisão',
in_review: 'Início da revisão',
returned: 'Devolução',
rejected: 'Rejeição',
approved: 'Aprovação',
published: 'Publicação',
archived: 'Arquivo',
withdrawn: 'Retirada da publicação',
publication_withdrawn: 'Retirada da publicação',
suspended: 'Suspensão da publicação',
submit: 'Submissão',
start_review: 'Início da revisão',
return: 'Devolução',
@@ -43,6 +55,10 @@ export default function EditorialWorkflow({
demographyUrl,
demographyPending = 0,
territorialUrl,
nativePending = { clusters: 0, territories: 0 },
subjectsUrl,
subjectsPending = 0,
zonesUrl,
}: {
resourceAccess: boolean;
opportunitiesUrl: string | null;
@@ -53,6 +69,10 @@ export default function EditorialWorkflow({
demographyUrl?: string | null;
demographyPending?: number;
territorialUrl?: string | null;
nativePending?: { clusters: number; territories: number; zones?: number };
zonesUrl?: string | null;
subjectsUrl?: string | null;
subjectsPending?: number;
filters?: { state?: string; kind?: string };
items: { data: Item[]; current_page: number; last_page: number };
notices: {
@@ -73,6 +93,17 @@ export default function EditorialWorkflow({
próprio.
</p>
<div className="flex flex-wrap gap-4">
{zonesUrl && (
<Link className="underline" href={zonesUrl}>
Zonas e indicadores: {nativePending.zones ?? 0}{' '}
pendentes
</Link>
)}
{subjectsUrl && (
<Link className="underline" href={subjectsUrl}>
Estruturas e relações: {subjectsPending} pendentes
</Link>
)}
{demographyUrl && (
<Link className="underline" href={demographyUrl}>
Demografia: {demographyPending} lotes pendentes
@@ -80,7 +111,8 @@ export default function EditorialWorkflow({
)}
{territorialUrl && (
<Link className="underline" href={territorialUrl}>
Conteúdos territoriais
Conteúdos territoriais: {nativePending.territories}{' '}
pendentes
</Link>
)}
{portfolioUrl && (
@@ -90,7 +122,8 @@ export default function EditorialWorkflow({
)}
{clustersUrl && (
<Link className="underline" href={clustersUrl}>
Competitividade dos clusters
Competitividade dos clusters: {nativePending.clusters}{' '}
pendentes
</Link>
)}
<Link className="underline" href="/dashboard">
@@ -57,9 +57,13 @@ const titles: Record<string, string> = {
export default function TerritorialEditorial({
roles,
actorId,
initialTerritory,
noticeVersion,
}: {
roles: string[];
actorId: string;
initialTerritory?: string | null;
noticeVersion?: number | null;
}) {
const [rows, setRows] = useState<Row[]>([]);
const [next, setNext] = useState<string | null>(null);
@@ -109,6 +113,7 @@ export default function TerritorialEditorial({
'/api/v1/geo/sources?limit=100',
).then((data) => setSources(data.data)),
]).catch(fail);
if (initialTerritory) void open(initialTerritory);
}, []);
const open = async (id: string) => {
setBusy(true);
@@ -223,6 +228,12 @@ export default function TerritorialEditorial({
até à publicação expressa.
</p>
{message && <p role="status">{message}</p>}
{noticeVersion && (
<p>
Este aviso refere-se à versão {noticeVersion}. Confira o
histórico e a versão actual antes de decidir.
</p>
)}
<ul className="space-y-2">
{rows.map((row) => (
<li key={row.id}>
+93 -1
View File
@@ -12,6 +12,7 @@ import type { ChainLink, Gap } from './value-network';
function ChainForm({ clusterId, chain }: { clusterId: string; chain?: Chain }) {
const form = useForm({
editorial_version: chain?.editorial_version ?? null,
code: chain?.code ?? '',
name: chain?.name ?? '',
stage: chain?.stage ?? 'input',
@@ -34,6 +35,11 @@ function ChainForm({ clusterId, chain }: { clusterId: string; chain?: Chain }) {
<h3 className="font-semibold">
{chain ? chain.name : 'Nova etapa'}
</h3>
{chain?.editorial_url && (
<Link className="underline" href={chain.editorial_url}>
Conferir proposta e publicação
</Link>
)}
<Field label="Código da etapa" error={form.errors.code}>
<input
className={control}
@@ -79,8 +85,12 @@ export default function Editor({
gaps,
competitiveness,
editorialDossier,
editorialSubject,
structuralEditorial = false,
}: {
editorialDossier?: string | null;
editorialSubject?: string | null;
structuralEditorial?: boolean;
structure: Structure;
record: StructureRecord | null;
projects: { id: string; name: string; code: string }[];
@@ -101,6 +111,8 @@ export default function Editor({
valid_until: string | null;
validation_status: string;
version_no: number;
editorial_version?: number;
editorial_url?: string;
editor_id: number | null;
}[];
}) {
@@ -133,6 +145,7 @@ export default function Editor({
const association = useForm({
project_id: '',
version_no: 0,
editorial_version: 0,
role: 'complementary',
association_type: 'potential',
source_reference: '',
@@ -142,6 +155,23 @@ export default function Editor({
});
return (
<Management title={`${record ? 'Editar' : 'Criar'} ${singular}`}>
{editorialSubject && (
<p>
As alterações ficam numa proposta privada.{' '}
<Link className="underline" href={editorialSubject}>
Conferir edição e publicar
</Link>
.
</p>
)}
{structuralEditorial && (
<Link
className="underline"
href="/management/editorial/subjects"
>
Propostas de estruturas, relações e cadeias de valor
</Link>
)}
<Link className="underline" href={`/management/${structure}`}>
Voltar a {titles[structure].toLowerCase()}
</Link>
@@ -351,7 +381,16 @@ export default function Editor({
{member.evidence_reference ?? 'Sem evidencia'} -{' '}
{member.valid_from ?? 'Sem inicio'} -{' '}
{member.valid_until ?? 'Sem termo'}
{member.validation_status === 'pending' &&
{member.editorial_url && (
<Link
className="underline"
href={member.editorial_url}
>
Rever proposta e publicação
</Link>
)}
{!structuralEditorial &&
member.validation_status === 'pending' &&
member.editor_id !== viewerId && (
<button
className={button}
@@ -404,6 +443,59 @@ export default function Editor({
member.project_id ===
e.target.value,
)?.version_no ?? 0,
editorial_version:
memberships.find(
(member) =>
member.project_id ===
e.target.value,
)?.editorial_version ?? 0,
role:
memberships.find(
(member) =>
member.project_id ===
e.target.value,
)?.role ?? 'complementary',
association_type:
memberships.find(
(member) =>
member.project_id ===
e.target.value,
)?.association_type ??
'potential',
source_reference:
memberships.find(
(member) =>
member.project_id ===
e.target.value,
)?.source_reference ?? '',
evidence_reference:
memberships.find(
(member) =>
member.project_id ===
e.target.value,
)?.evidence_reference ?? '',
valid_from:
memberships
.find(
(member) =>
member.project_id ===
e.target.value,
)
?.valid_from?.slice(
0,
10,
) ?? '',
valid_until:
memberships
.find(
(member) =>
member.project_id ===
e.target.value,
)
?.valid_until?.slice(
0,
10,
) ?? '',
}))
}
>
+8 -1
View File
@@ -41,4 +41,11 @@ export const roles: Record<string, string> = {
complementary: 'Complementar',
supporting: 'Apoio',
};
export type Chain = { id: string; code: string; name: string; stage: string };
export type Chain = {
id: string;
code: string;
name: string;
stage: string;
editorial_version?: number;
editorial_url?: string;
};
@@ -17,6 +17,8 @@ export type Gap = {
rationale: string;
status: string;
call_to_action: string;
editorial_version?: number;
editorial_url?: string;
};
const linkTypes: Record<string, string> = {
supplies: 'Fornece a',
@@ -72,6 +74,7 @@ function GapForm({
gap?: Gap;
}) {
const form = useForm({
editorial_version: gap?.editorial_version ?? null,
value_chain_id: gap?.value_chain_id ?? '',
title: gap?.title ?? '',
location: gap?.location ?? '',
+2
View File
@@ -66,7 +66,9 @@ Route::middleware([CorrelationId::class, 'auth', 'verified', EnsureScopedPortalA
Route::get('/{id}', [InvestmentZoneController::class, 'show'])->whereUuid('id');
Route::post('/', [InvestmentZoneController::class, 'save']);
Route::put('/{id}', [InvestmentZoneController::class, 'save'])->whereUuid('id');
Route::post('/{id}/editorial/{action}', [InvestmentZoneController::class, 'editorialReview'])->whereUuid('id')->whereIn('action', ['submit', 'start_review', 'approve', 'return', 'reject', 'archive']);
Route::post('/{id}/{action}', [InvestmentZoneController::class, 'transition'])->whereUuid('id')->whereIn('action', ['publish', 'suspend', 'reconcile']);
Route::get('/{id}/revisions', [InvestmentZoneController::class, 'revisions'])->whereUuid('id');
Route::get('/{id}/revisions/{version}', [InvestmentZoneController::class, 'revision'])->whereUuid('id')->whereNumber('version');
});
});
+5
View File
@@ -97,6 +97,11 @@ Route::get('/{legacySlug}', [LegacyContentController::class, 'article'])->whereI
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/subjects', [PortalEditorialWorkflowController::class, 'subjects']);
Route::get('/management/editorial/subjects/{kind}/{id}', [PortalEditorialWorkflowController::class, 'subject'])->whereUuid('id');
Route::get('/management/editorial/subjects/{kind}/{id}/choices', [PortalEditorialWorkflowController::class, 'subjectChoices'])->whereUuid('id');
Route::post('/management/editorial/subjects/{kind}/{id}/drafts', [PortalEditorialWorkflowController::class, 'subjectSave'])->whereUuid('id');
Route::post('/management/editorial/subjects/{kind}/{id}/editions/{edition}', [PortalEditorialWorkflowController::class, 'subjectTransition'])->whereUuid(['id', 'edition']);
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']);
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace Tests\Feature;
use App\Models\EconomicCluster;
use App\Models\User;
use App\Services\ClusterCompetitiveness;
use App\Services\PortalEditorialNativeNotices;
use App\Services\PortalEditorialWorkflow;
use App\Services\TerritorialRegistry;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class EditorialNativeNoticeTest extends TestCase
{
use AuthenticatesQaActors,DatabaseTransactions;
private User $author;
private User $reviewer;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$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.cluster_competitiveness' => true, 'editorial_workflow.territorial_versions' => true, 'territorial.enabled' => true, 'territorial.editors' => [$this->author->email], 'territorial.reviewers' => [$this->reviewer->email], 'territorial.publishers' => [$this->reviewer->email]]);
$this->actingAsWithMfa($this->reviewer);
$this->actingAsWithMfa($this->author);
}
public function test_cluster_events_use_the_common_inbox_and_recheck_current_scope_and_event_integrity(): void
{
$cluster = EconomicCluster::create(['code' => 'NOTICE-'.Str::random(12), 'name' => 'Synthetic notice cluster', 'sector' => 'Energy', 'status' => 'planned']);
$draft = ['version' => 1, 'clusterId' => $cluster->id, 'sector' => 'Energy', 'region' => 'Synthetic territory', 'forces' => []];
foreach (ClusterCompetitiveness::FORCES as $force) {
$draft['forces'][$force] = ['pressure' => 'medium', ...array_fill_keys(ClusterCompetitiveness::FIELDS, 'Synthetic evidence and limitations')];
}
$service = app(ClusterCompetitiveness::class);
$service->change($cluster, $this->author->id, 0, 'saved', ['draft' => $draft]);
$this->assertDatabaseMissing('portal_editorial_notifications', ['user_id' => $this->reviewer->id, 'action' => 'saved']);
$state = $service->change($cluster, $this->author->id, 1, 'submitted', ['reason' => 'Synthetic review requested']);
$notice = DB::table('portal_editorial_notifications')->where('user_id', $this->reviewer->id)->where('action', 'submitted')->first();
$this->assertNotNull($notice);
$this->assertTrue(app(PortalEditorialWorkflow::class)->visibleNotice($this->reviewer, $notice));
app(PortalEditorialNativeNotices::class)->emit($this->author, $notice->native_event_id, [$this->reviewer->id]);
$this->assertSame(1, DB::table('portal_editorial_notifications')->where('native_event_id', $notice->native_event_id)->where('user_id', $this->reviewer->id)->count());
$present = app(PortalEditorialNativeNotices::class)->present($notice);
$this->assertStringContainsString($cluster->id.'/edit?notice_version=2', $present['href']);
$this->assertSame('Competitividade — versão 2', $present['reference']);
$this->actingAsWithMfa($this->reviewer)->postJson('/management/editorial/notices/'.$notice->id.'/read')->assertOk();
config(['portal.managers' => [$this->author->email]]);
$this->assertFalse(app(PortalEditorialWorkflow::class)->visibleNotice($this->reviewer, $notice));
$this->postJson('/management/editorial/notices/'.$notice->id.'/read')->assertNotFound();
config(['portal.managers' => [$this->author->email, $this->reviewer->email]]);
DB::table('portal_editorial_notifications')->where('id', $notice->id)->update(['content_hash' => str_repeat('0', 64)]);
$tampered = DB::table('portal_editorial_notifications')->where('id', $notice->id)->first();
$this->assertFalse(app(PortalEditorialWorkflow::class)->visibleNotice($this->reviewer, $tampered));
}
public function test_territory_events_open_the_correct_record_without_publishing_or_granting_access(): void
{
$source = (string) Str::uuid();
DB::table('geo_sources')->insert(['id' => $source, 'name' => 'Synthetic source', 'reference' => 'Synthetic source reference', 'checksum' => str_repeat('f', 64), 'source_version' => 'qa', 'collected_at' => today(), 'editor_id' => $this->author->id]);
$registry = app(TerritorialRegistry::class);
$id = $registry->save($this->author, ['name' => 'Synthetic country', 'code' => 'NOTICE-'.Str::random(12), 'location_type' => 'country', 'source_id' => $source, 'valid_from' => '2020-01-01', 'reason' => 'Synthetic territorial preparation']);
$baseline = app(PortalEditorialNativeNotices::class)->pending($this->reviewer);
$registry->transitionEditorial($this->author, $id, ['action' => 'pending_review', 'version_no' => 1, 'reason' => 'Synthetic territorial review requested']);
$notice = DB::table('portal_editorial_notifications')->where('user_id', $this->reviewer->id)->where('action', 'pending_review')->first();
$this->assertNotNull($notice);
$this->assertTrue(app(PortalEditorialWorkflow::class)->visibleNotice($this->reviewer, $notice));
$this->assertStringContainsString('territory='.$id, app(PortalEditorialNativeNotices::class)->present($notice)['href']);
$this->actingAsWithMfa($this->reviewer)->get('/management/territorial/editorial?territory='.$id.'&notice_version=2')->assertOk();
$this->assertFalse($registry->published()->where('g.id', $id)->exists());
$registry->transitionEditorial($this->reviewer, $id, ['action' => 'in_review', 'version_no' => 2, 'reason' => 'Synthetic reviewer starts review']);
$this->assertSame($baseline['territories'], app(PortalEditorialNativeNotices::class)->pending($this->reviewer)['territories']);
$registry->transitionEditorial($this->reviewer, $id, ['action' => 'archived', 'version_no' => 3, 'reason' => 'Synthetic proposal archived']);
$this->assertSame($baseline['territories'] - 1, app(PortalEditorialNativeNotices::class)->pending($this->reviewer)['territories']);
$this->assertNotEmpty(app(PortalEditorialWorkflow::class)->notices($this->author));
}
public function test_email_designation_is_explicit_revocable_and_does_not_grant_content_management(): void
{
$outsider = User::factory()->create();
$workflow = app(PortalEditorialWorkflow::class);
$this->assertFalse($workflow->legalReviewer($outsider));
config(['editorial_workflow.legal_reviewer_emails' => [strtolower($outsider->email)]]);
$this->assertTrue($workflow->legalReviewer($outsider));
$this->assertFalse($outsider->can('manage-portal'));
$outsider->forceFill(['email_verified_at' => null])->save();
$this->assertFalse($workflow->legalReviewer($outsider));
config(['editorial_workflow.legal_reviewer_emails' => []]);
$this->assertFalse($workflow->legalReviewer($this->reviewer));
}
}
@@ -0,0 +1,207 @@
<?php
namespace Tests\Feature;
use App\Models\EconomicCluster;
use App\Models\Project;
use App\Models\User;
use App\Models\ValueChain;
use App\Models\ValueChainLink;
use App\Services\PortalEditorialSubjects;
use App\Services\PortalEditorialWorkflow;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class EditorialStructuralSubjectTest extends TestCase
{
use AuthenticatesQaActors,DatabaseTransactions;
private User $author;
private User $reviewer;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$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.structural_subjects' => true]);
$this->actingAsWithMfa($this->author);
}
private function move(\stdClass $edition, string $action, ?User $actor = null): \stdClass
{
$actor ??= $action === 'submit' ? $this->author : $this->reviewer;
$this->actingAsWithMfa($actor);
return app(PortalEditorialWorkflow::class)->transition($actor, $edition->subject_key, $edition->id, ['action' => $action, 'version' => $edition->version, 'reason' => 'Synthetic subject review decision'], false, false, $edition->subject_kind);
}
private function approve(\stdClass $edition): \stdClass
{
foreach (['submit', 'start_review', 'approve'] as $action) {
$edition = $this->move($edition, $action);
}
return $edition;
}
public function test_a_structure_without_a_fiche_keeps_its_public_fields_until_explicit_publication(): void
{
$record = Project::create(['code' => 'SUB-'.Str::random(10), 'name' => 'Original public name', 'status' => 'active', 'data_classification' => 'public']);
$before = $record->fresh()->getAttributes();
$dossiersBefore = DB::table('investment_dossiers')->count();
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'projects', $record->id, 1, ['name' => 'Proposed private name', 'budget_amount' => '999999999999999999.99', 'budget_currency' => 'MZN']);
$this->assertEquals($before, $record->fresh()->getAttributes());
$this->assertSame(2, $edition->revision_no);
$edition = $this->approve($edition);
$this->assertEquals($before, $record->fresh()->getAttributes());
$edition = $this->move($edition, 'publish');
$this->assertSame('Proposed private name', $record->fresh()->name);
$this->assertSame('999999999999999999.99', $record->fresh()->getRawOriginal('budget_amount'));
$this->assertDatabaseCount('investment_dossiers', $dossiersBefore);
$this->assertSame('published', $edition->state);
}
public function test_relation_edits_do_not_invalidate_or_replace_the_current_public_association_before_publication(): void
{
$project = Project::create(['code' => 'REL-'.Str::random(10), 'name' => 'Synthetic project', 'status' => 'active']);
$cluster = EconomicCluster::create(['code' => 'REL-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$id = (string) Str::uuid();
DB::table('cluster_projects')->insert(['id' => $id, 'cluster_id' => $cluster->id, 'project_id' => $project->id, 'role' => 'anchor', 'association_type' => 'potential', 'source_reference' => 'Synthetic identified source', 'evidence_reference' => 'Synthetic page 2', 'valid_from' => '2020-01-01', 'validation_status' => 'validated', 'editor_id' => $this->author->id, 'validator_id' => $this->reviewer->id, 'version_no' => 1, 'data_classification' => 'public']);
$before = DB::table('cluster_projects')->find($id);
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'cluster-project', $id, 1, ['role' => 'supporting']);
$edition = $this->move($edition, 'submit');
$edition = $this->move($edition, 'start_review');
try {
$this->move($edition, 'approve', $this->author);
$this->fail('Contributor approval must be rejected');
} catch (HttpException $e) {
$this->assertSame(403, $e->getStatusCode());
}
$edition = $this->move($edition, 'approve');
$this->assertEquals($before, DB::table('cluster_projects')->find($id));
$this->move($edition, 'publish');
$this->assertSame('supporting', DB::table('cluster_projects')->find($id)->role);
$this->assertSame('validated', DB::table('cluster_projects')->find($id)->validation_status);
}
public function test_new_value_chain_is_private_until_published_and_archiving_keeps_its_history(): void
{
$cluster = EconomicCluster::create(['code' => 'CHAIN-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$chain = ValueChain::create(['cluster_id' => $cluster->id, 'code' => 'QA-'.Str::random(8), 'name' => 'Private new chain', 'stage' => 'production', 'deleted_at' => now()]);
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'value-chains', $chain->id, 0, [], true);
$this->assertNull(ValueChain::find($chain->id));
$edition = $this->approve($edition);
$this->assertNull(ValueChain::find($chain->id));
$edition = $this->move($edition, 'publish');
$this->assertNotNull(ValueChain::find($chain->id));
$edition = $this->move($edition, 'archive');
$this->assertNull(ValueChain::find($chain->id));
$this->assertNotNull(app(PortalEditorialWorkflow::class)->latestSubject('value-chains', $chain->id));
}
public function test_existing_editor_routes_and_private_subject_endpoints_preserve_context_and_permissions(): void
{
$record = Project::create(['code' => 'HTTP-'.Str::random(10), 'name' => 'Original HTTP name', 'status' => 'active']);
$this->put('/management/projects/'.$record->id, ['editorial_version' => 1, 'code' => $record->code, 'name' => 'Private HTTP name', 'status' => 'active'])->assertRedirect()->assertSessionHasNoErrors();
$this->assertSame('Original HTTP name', $record->fresh()->name);
$base = '/management/editorial/subjects/projects/'.$record->id;
$this->getJson($base)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('edition.payload.resource._subject.fields.name', 'Private HTTP name');
$this->getJson('/management/editorial/subjects')->assertOk();
$this->getJson($base.'/choices?field=primary_territory_id')->assertOk();
$this->getJson($base.'/choices?field=password')->assertNotFound();
$this->postJson($base.'/drafts', ['expected_revision' => 2, 'fields' => ['data_classification' => 'public']])->assertUnprocessable();
$this->postJson($base.'/drafts', ['expected_revision' => 2, 'fields' => ['name' => 'Deliberate revised name']])->assertCreated();
$this->getJson($base)->assertJsonPath('edition.payload.resource._subject.fields.name', 'Deliberate revised name');
$outsider = User::factory()->create();
$this->actingAsWithMfa($outsider)->getJson($base)->assertForbidden();
$this->postJson($base.'/drafts', ['expected_revision' => 3, 'fields' => ['name' => 'Forbidden']])->assertForbidden();
$this->assertSame('Original HTTP name', $record->fresh()->name);
}
public function test_withdrawal_of_an_existing_chain_link_is_a_separate_reviewed_publication(): void
{
$cluster = EconomicCluster::create(['code' => 'WITHDRAW-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$a = ValueChain::create(['cluster_id' => $cluster->id, 'code' => 'A-'.Str::random(5), 'name' => 'Origin', 'stage' => 'production', 'data_classification' => 'public']);
$b = ValueChain::create(['cluster_id' => $cluster->id, 'code' => 'B-'.Str::random(5), 'name' => 'Destination', 'stage' => 'processing', 'data_classification' => 'public']);
$link = ValueChainLink::create(['from_value_chain_id' => $a->id, 'to_value_chain_id' => $b->id, 'link_type' => 'supplies', 'data_classification' => 'public']);
$this->delete('/management/clusters/'.$cluster->id.'/chain-links/'.$link->id)->assertRedirect('/management/editorial/subjects/value-chain-links/'.$link->id);
$this->assertNotNull(ValueChainLink::find($link->id));
$edition = app(PortalEditorialWorkflow::class)->latestSubject('value-chain-links', $link->id);
$this->assertTrue(app(PortalEditorialWorkflow::class)->payload($edition)['resource']['_subject']['withdraw']);
$edition = $this->approve($edition);
$this->assertNotNull(ValueChainLink::find($link->id));
$this->move($edition, 'publish');
$this->assertNull(ValueChainLink::find($link->id));
$this->getJson('/management/editorial/subjects/value-chain-links/'.$link->id)->assertOk();
}
public function test_new_chain_form_creates_a_private_proposal_in_the_existing_model(): void
{
$cluster = EconomicCluster::create(['code' => 'FORM-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$code = 'NEW-'.Str::random(10);
$this->post('/management/clusters/'.$cluster->id.'/chains', ['code' => $code, 'name' => 'Private new stage', 'stage' => 'production'])->assertRedirect()->assertSessionHasNoErrors();
$chain = ValueChain::withTrashed()->where('code', $code)->firstOrFail();
$this->assertNull(ValueChain::find($chain->id));
$edition = app(PortalEditorialWorkflow::class)->latestSubject('value-chains', $chain->id);
$this->assertSame('draft', $edition->state);
$this->getJson('/management/editorial/subjects/value-chains/'.$chain->id)->assertOk()->assertJsonPath('edition.payload.resource._subject.new_record', true);
}
public function test_duplicate_relation_publication_is_refused_and_keeps_both_original_links(): void
{
$cluster = EconomicCluster::create(['code' => 'DUP-'.Str::random(10), 'name' => 'Synthetic cluster', 'sector' => 'Energy', 'status' => 'active']);
$chains = collect(['A', 'B', 'C'])->map(fn ($code) => ValueChain::create(['cluster_id' => $cluster->id, 'code' => $code, 'name' => $code, 'stage' => 'production']));
$existing = ValueChainLink::create(['from_value_chain_id' => $chains[0]->id, 'to_value_chain_id' => $chains[1]->id, 'link_type' => 'supplies']);
$edited = ValueChainLink::create(['from_value_chain_id' => $chains[0]->id, 'to_value_chain_id' => $chains[2]->id, 'link_type' => 'supplies']);
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'value-chain-links', $edited->id, 1, ['to_value_chain_id' => $chains[1]->id]);
$edition = $this->approve($edition);
try {
$this->move($edition, 'publish');
$this->fail('Duplicate must be refused before writing');
} catch (HttpException $e) {
$this->assertSame(409, $e->getStatusCode());
}
$this->assertSame($chains[2]->id, $edited->fresh()->to_value_chain_id);
$this->assertNotNull($existing->fresh());
}
public function test_partial_edits_validate_against_the_preserved_dates_and_currency(): void
{
$record = Project::create(['code' => 'DATES-'.Str::random(10), 'name' => 'Original', 'status' => 'active', 'start_date' => '2026-05-01']);
$base = '/management/editorial/subjects/projects/'.$record->id.'/drafts';
$this->postJson($base, ['expected_revision' => 1, 'fields' => ['end_date' => '2026-04-01']])->assertUnprocessable();
$this->postJson($base, ['expected_revision' => 1, 'fields' => ['budget_amount' => '2000.25']])->assertUnprocessable();
$this->assertSame(0, DB::table('portal_editorial_editions')->where('subject_key', $record->id)->count());
$this->assertSame('Original', $record->fresh()->name);
}
public function test_context_changes_and_disabled_workflow_do_not_allow_legacy_bypass(): void
{
$record = Project::create(['code' => 'STALE-'.Str::random(10), 'name' => 'Original', 'status' => 'active']);
$edition = app(PortalEditorialWorkflow::class)->saveSubject($this->author, 'projects', $record->id, 1, ['name' => 'Private']);
$edition = $this->approve($edition);
$record->forceFill(['description' => 'Changed contextual evidence'])->save();
try {
$this->move($edition, 'publish');
$this->fail('Context changed');
} catch (HttpException $e) {
$this->assertSame(409, $e->getStatusCode());
}
$this->assertSame('Original', $record->fresh()->name);
config(['editorial_workflow.structural_subjects' => false]);
try {
app(PortalEditorialSubjects::class)->writes('projects', $record->id);
$this->fail('Legacy bypass');
} catch (HttpException $e) {
$this->assertSame(503, $e->getStatusCode());
}
}
}
@@ -0,0 +1,163 @@
<?php
namespace Tests\Feature;
use App\Models\InvestmentZone;
use App\Models\User;
use App\Services\InvestmentMapIndicators;
use App\Services\InvestmentZoneOperationalDetails;
use App\Services\InvestmentZoneOperationalProfile;
use App\Services\InvestmentZones;
use App\Services\PortalEditorialNativeNotices;
use App\Services\PortalEditorialWorkflow;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class ZoneStatisticsEditorialTest extends TestCase
{
use AuthenticatesQaActors,DatabaseTransactions;
private User $author;
private User $reviewer;
private InvestmentZone $zone;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->assertTrue(Schema::hasColumn('investment_zones', 'editorial_draft'));
$this->assertTrue(Schema::hasColumn('investment_zones', 'operational_details'));
$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.zone_review' => true]);
$this->actingAsWithMfa($this->reviewer);
$this->actingAsWithMfa($this->author);
$country = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $country, 'code' => 'ZONE-'.Str::random(12), 'name' => 'Synthetic reviewed country', 'location_type' => 'country', 'data_classification' => 'public', 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$country]);
$province = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $province, 'parent_id' => $country, 'code' => 'ZONE-'.Str::random(12), 'name' => 'Synthetic reviewed province', 'location_type' => 'province', 'data_classification' => 'public', 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$province]);
// Previously published synthetic baseline; this is not an approval of a real source.
$this->zone = InvestmentZone::create(['code' => 'ZONE-'.Str::random(10), 'name' => 'Original public zone', 'description' => 'Synthetic informational description', 'category' => 'industrial_park', 'physical_category' => 'industrial_park', 'legal_regime' => 'pending', 'primary_territory_id' => $province, 'territory_version' => 1, 'version_no' => 1, 'publication_status' => 'published', 'validation_status' => 'validated', 'editor_id' => $this->author->id, 'reviewer_id' => $this->author->id, 'published_at' => now(),
'source_evidence' => [['title' => 'Synthetic institutional report', 'url' => 'https://example.test/institutional', 'source_type' => 'institutional', 'supports' => 'Synthetic evidence of the identity and location only']]]);
}
private function profile(): InvestmentZoneOperationalProfile
{
return app(InvestmentZoneOperationalProfile::class);
}
private function prepare(): void
{
$this->actingAsWithMfa($this->author);
$fields = ['version_no' => $this->profile()->workingVersion($this->zone->fresh()), 'name' => 'Private revised zone', 'operational_details' => ['schema_version' => 1, 'integral' => ['schema_version' => 1, 'observations' => [['id' => (string) Str::uuid(), 'label' => 'Synthetic investment observation', 'knowledge' => 'documented', 'disclosure' => true, 'source_reference' => 'Synthetic report page 2', 'source_date' => '2026-01-15', 'scope' => 'This synthetic zone only', 'indicator' => 'investment', 'measure_kind' => 'observed', 'value' => '9007199254740.25', 'unit' => 'MZN', 'period_start' => '2025-01-01', 'period_end' => '2025-12-31', 'population' => 'Declared establishments', 'methodology' => 'Synthetic documentary record']]]]];
app(InvestmentZones::class)->save($this->zone->id, $fields, $this->author->id);
}
private function decision(string $action, ?User $actor = null): array
{
$actor ??= $action === 'submit' ? $this->author : $this->reviewer;
$this->actingAsWithMfa($actor);
return $this->profile()->review($this->zone->id, $actor, $this->profile()->workingVersion($this->zone->fresh()), $action, 'Synthetic review decision with source');
}
private function approve(): void
{
foreach (['submit', 'start_review', 'approve'] as $action) {
$this->decision($action);
}
}
public function test_indicator_preparation_and_approval_preserve_the_public_projection_until_explicit_publication(): void
{
$before = Arr::except($this->zone->fresh()->getAttributes(), ['editorial_draft']);
$this->prepare();
$this->assertSame($before, Arr::except($this->zone->fresh()->getAttributes(), ['editorial_draft']));
$this->assertSame([], app(InvestmentZoneOperationalDetails::class)->publicData($this->zone->fresh()));
$this->approve();
$this->assertSame($before, Arr::except($this->zone->fresh()->getAttributes(), ['editorial_draft']));
$this->actingAsWithMfa($this->reviewer);
$saved = app(InvestmentZones::class)->publish($this->zone->id, $this->profile()->workingVersion($this->zone->fresh()), $this->reviewer->id);
$this->assertSame('Private revised zone', $saved->name);
$rows = app(InvestmentMapIndicators::class)->forZones(collect([$saved]))['records'];
$this->assertCount(1, $rows);
$this->assertSame('9007199254740.25', $rows[0]['value']);
$this->assertSame('observed', $rows[0]['measure_kind']);
$this->assertSame('This synthetic zone only', $rows[0]['scope']);
$this->assertSame('Synthetic report page 2', $rows[0]['source_reference']);
$this->assertNull($this->profile()->draft($saved));
$this->assertNotEmpty(app(PortalEditorialWorkflow::class)->notices($this->author));
}
public function test_editing_after_approval_requires_a_new_decision_and_direct_publish_cannot_bypass_it(): void
{
$this->prepare();
$this->approve();
$this->actingAsWithMfa($this->author);
app(InvestmentZones::class)->save($this->zone->id, ['version_no' => $this->profile()->workingVersion($this->zone->fresh()), 'description' => 'Deliberate later change'], $this->author->id);
$this->assertSame('draft', $this->profile()->management($this->zone->fresh())['editorial_workflow']['state']);
try {
app(InvestmentZones::class)->publish($this->zone->id, $this->profile()->workingVersion($this->zone->fresh()), $this->author->id);
$this->fail('Approval must be renewed');
} catch (HttpException $e) {
$this->assertSame(409, $e->getStatusCode());
}
$this->assertSame('Original public zone', $this->zone->fresh()->name);
}
public function test_public_suspension_preserves_the_private_review_and_approved_exact_content(): void
{
$this->prepare();
$this->approve();
$this->actingAsWithMfa($this->reviewer);
$saved = app(InvestmentZones::class)->suspend($this->zone->id, $this->profile()->workingVersion($this->zone->fresh()), $this->reviewer->id);
$working = $this->profile()->management($saved);
$this->assertSame('approved', $working['editorial_workflow']['state']);
$this->assertTrue($working['editorial_workflow']['fresh']);
$this->assertSame('suspended', $saved->publication_status);
app(InvestmentZones::class)->publish($saved->id, $working['version_no'], $this->reviewer->id);
$this->assertSame('Private revised zone', $saved->fresh()->name);
}
public function test_history_recovers_the_exact_private_observation_without_overwriting_current_content(): void
{
$this->prepare();
$version = $this->profile()->workingVersion($this->zone->fresh());
$url = '/api/v2/investment/admin/zones/'.$this->zone->id.'/revisions/'.$version;
$before = $this->zone->fresh()->getAttributes();
$this->getJson($url)->assertOk()->assertJsonPath('fields.name', 'Private revised zone')->assertJsonPath('observations.0.value', '9007199254740.25')->assertJsonMissingPath('fields.editorial_draft')->assertJsonMissingPath('private_ciphertext');
$this->assertSame($before, $this->zone->fresh()->getAttributes());
$this->getJson(str_replace($this->zone->id, (string) Str::uuid(), $url))->assertNotFound();
$this->actingAsWithMfa(User::factory()->create())->getJson($url)->assertForbidden();
}
public function test_http_decisions_require_scope_fresh_mfa_and_preserve_source_and_history(): void
{
$this->prepare();
$version = $this->profile()->workingVersion($this->zone->fresh());
$url = '/api/v2/investment/admin/zones/'.$this->zone->id.'/editorial/submit';
$this->withSession(['portal.mfa_at' => time() - 1000])->postJson($url, ['version_no' => $version, 'reason' => 'Synthetic decision reason'])->assertForbidden();
$this->actingAsWithMfa($this->author)->postJson($url, ['version_no' => $version, 'reason' => 'Synthetic decision reason'])->assertOk()->assertJsonPath('editorial_workflow.state', 'submitted');
$this->assertGreaterThan(1, app(PortalEditorialNativeNotices::class)->pending($this->reviewer)['zones'] + 1);
$outsider = User::factory()->create();
$this->actingAsWithMfa($outsider)->postJson($url, ['version_no' => $version, 'reason' => 'Synthetic decision reason'])->assertForbidden();
config(['editorial_workflow.zone_review' => false]);
$this->actingAsWithMfa($this->author);
try {
app(InvestmentZones::class)->save($this->zone->id, ['version_no' => $version, 'name' => 'Forbidden legacy overwrite'], $this->author->id);
$this->fail('Disabling must preserve workflow');
} catch (HttpException $e) {
$this->assertSame(503, $e->getStatusCode());
}
$this->assertSame('Original public zone', $this->zone->fresh()->name);
}
}
@@ -0,0 +1,55 @@
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);
let states = [], formData;
const mockReact = { ...React, useState: initial => [states.length ? states.shift() : initial, () => {}], useEffect() {} };
function load(file, appended = '') {
const code = ts.transpileModule(fs.readFileSync(new URL('../../resources/js/pages/' + file, import.meta.url), 'utf8') + appended, { 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 === 'react') return mockReact;
if (name === 'react/jsx-runtime') return require(name);
if (name === '@inertiajs/react') return { useForm: data => { formData = data; return {data, errors: {}, processing: false, setData() {}, post() {}}; } };
if (name === './shared') return { Field: ({label, children}) => React.createElement('label', null, label, children), button: '', control: '', input: '', card: '' };
return {};
}, module, module.exports);
return module.exports;
}
const { ZoneReview, ZoneHistory } = load('investment/zone.tsx', '\nexport { ZoneReview, ZoneHistory };');
const SubjectForm = load('management/editorial-subject-form.tsx').default;
const zone = { id: 'synthetic-zone', version_no: 8, editorial_workflow: { state: 'in_review', content_version: 3, fresh: true, can_approve: true } };
const renderReview = (patch = {}, dirty = false) => { states = ['Documentary review reason']; return renderToStaticMarkup(React.createElement(ZoneReview, {zone: {...zone, editorial_workflow: {...zone.editorial_workflow, ...patch}}, busy: false, dirty, action() {}})); };
test('zone review separates approval from publication and hides unauthorised approval', () => {
const html = renderReview();
assert.match(html, /Aprovar esta edição/);
assert.doesNotMatch(html, />Publicar/);
assert.doesNotMatch(renderReview({can_approve: false}), /Aprovar esta edição/);
});
test('changed sources and unsaved edits disable decisions without hiding the history', () => {
for (const html of [renderReview({fresh: false}), renderReview({}, true)]) assert.match(html, /disabled=""[^>]*>Aprovar esta edição/);
assert.match(renderReview({fresh: false}), /fonte ou anexos mudaram/);
});
test('history displays exact observation and source without applying it', () => {
states = [{version_no: 4, fields: {name: 'Historical zone', description: 'Preserved description'}, observations: [{label: 'Investment', value: '9007199254740.25', unit: 'MZN', scope: 'Synthetic zone', source_reference: 'Report page 2'}]}, '', false];
const html = renderToStaticMarkup(React.createElement(ZoneHistory, {id: 'synthetic-zone', revisions: [{version_no: 4, action: 'profile_prepared', created_at: '2026-11-14'}]}));
assert.match(html, /9007199254740.25/);
assert.match(html, /Report page 2/);
assert.match(html, /preserva a versão actual/);
assert.doesNotMatch(html, /Restaurar|Publicar/);
});
test('structural form preserves revision and selected values and opens progressively', () => {
const props = {base: '/management/editorial/subjects/projects/synthetic', revision: 7, fields: [{name: 'name', label: 'Designação', type: 'text', options: []}], initial: {name: 'Deliberate edit'}};
states = [false];
assert.doesNotMatch(renderToStaticMarkup(React.createElement(SubjectForm, props)), /textarea/);
states = [true];
const html = renderToStaticMarkup(React.createElement(SubjectForm, props));
assert.equal(formData.expected_revision, 7);
assert.equal(formData.fields.name, 'Deliberate edit');
assert.match(html, /Deliberate edit/);
assert.match(html, /publicação expressa da decisão/);
});
+2 -1
View File
@@ -24,6 +24,7 @@ const code = ts.transpileModule(source, {
const module = { exports: {} };
new Function('require', 'module', 'exports', code)(
(name) => {
if (name === './editorial-subject-form') return { default: () => null };
if (name === '../portfolio/shared')
return { labels: { description: 'Visão e proposta de valor' } };
if (name === '@inertiajs/react')
@@ -130,7 +131,7 @@ test('approved edition offers explicit publication; stale files and historical v
const stale = render({
edition: { ...props.edition, state: 'approved', fresh: false },
});
assert.match(stale, /Os anexos mudaram/);
assert.match(stale, /O conteúdo, contexto ou anexos mudaram/);
assert.doesNotMatch(stale, /value="publish"/);
const staleReview = render({
edition: { ...props.edition, state: 'in_review', fresh: false },