Complete grouped collaborative editorial queues and native review coverage
tests / ci (push) Waiting to run
tests / ci (push) Waiting to run
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'] ?? []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.'¬ice_version='.$version,
|
||||
'reference' => ($cluster ? 'Competitividade — versão ' : 'Território — versão ').$version];
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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' => []],
|
||||
];
|
||||
|
||||
+35
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+35
@@ -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
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
) ?? '',
|
||||
}))
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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.'¬ice_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/);
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user