From 7f1314972358488ea13bea9560e9dd99de19ca18 Mon Sep 17 00:00:00 2001 From: nala Date: Tue, 10 Nov 2026 10:41:50 +0200 Subject: [PATCH] feat(opportunities): curate highlights with native authority and audited confirmation --- .../OpportunityCurationController.php | 30 ++ .../OpportunityManagementController.php | 3 + app/Services/OpportunityCuration.php | 132 ++++++++ app/Services/OpportunityProfileWorkflow.php | 5 + app/Services/PortalProfileAccess.php | 2 + .../pages/management/opportunity-curation.tsx | 284 ++++++++++++++++++ .../js/pages/management/opportunity-draft.tsx | 7 + routes/web.php | 7 + .../Unit/OpportunityCurationAuthorityTest.php | 85 ++++++ tests/frontend/opportunity-curation.test.mjs | 133 ++++++++ 10 files changed, 688 insertions(+) create mode 100644 app/Http/Controllers/OpportunityCurationController.php create mode 100644 app/Services/OpportunityCuration.php create mode 100644 resources/js/pages/management/opportunity-curation.tsx create mode 100644 tests/Unit/OpportunityCurationAuthorityTest.php create mode 100644 tests/frontend/opportunity-curation.test.mjs diff --git a/app/Http/Controllers/OpportunityCurationController.php b/app/Http/Controllers/OpportunityCurationController.php new file mode 100644 index 00000000..79a69784 --- /dev/null +++ b/app/Http/Controllers/OpportunityCurationController.php @@ -0,0 +1,30 @@ +path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities'; + $filters = $request->validate(['page' => 'nullable|integer|min:1|max:1000000']); + + return Inertia::render('management/opportunity-curation', [...$service->state($request->user(), $id, (int) ($filters['page'] ?? 1)), 'basePath' => $base]); + } + + public function preview(Request $request, int $id, OpportunityCuration $service): JsonResponse + { + return response()->json($service->preview($request->user(), $id, $request->all()))->header('Cache-Control', 'private, no-store'); + } + + public function confirm(Request $request, int $id, OpportunityCuration $service): JsonResponse + { + return response()->json($service->confirm($request->user(), $id, $request->all()))->header('Cache-Control', 'private, no-store'); + } +} diff --git a/app/Http/Controllers/OpportunityManagementController.php b/app/Http/Controllers/OpportunityManagementController.php index f438477f..5862fe1c 100644 --- a/app/Http/Controllers/OpportunityManagementController.php +++ b/app/Http/Controllers/OpportunityManagementController.php @@ -8,6 +8,7 @@ use App\Models\Sector; use App\Services\InvestmentGeography; use App\Services\OpportunityCanonicalContract; use App\Services\OpportunityCanonicalResponse; +use App\Services\OpportunityCuration; use App\Services\OpportunityManagement; use App\Services\OpportunityManagementInstallation; use App\Services\OpportunityMaturity; @@ -130,6 +131,8 @@ class OpportunityManagementController extends Controller $access->authorize($request->user(), 'view', $record); } Inertia::share(['basePath' => str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades' : '/management/opportunities', + 'curationHref' => $record && app(OpportunityCuration::class)->allowed($request->user(), $record) + ? (str_starts_with($request->path(), 'minhas-oportunidades') ? '/minhas-oportunidades/' : '/management/opportunities/').$record->id.'/destaque' : null, 'canonicalOptions' => ($record?->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades')) ? app(OpportunityCanonicalContract::class)->options($request->user(), $record) : null, 'workflowState' => $record ? $access->state($record) : null, 'profileReauthenticationRequired' => $record && $record->owner_user_id !== null diff --git a/app/Services/OpportunityCuration.php b/app/Services/OpportunityCuration.php new file mode 100644 index 00000000..3efa96e6 --- /dev/null +++ b/app/Services/OpportunityCuration.php @@ -0,0 +1,132 @@ +management->installed() || ! $this->access->canView($actor, $record)) { + return false; + } + if ($record->owner_user_id === null) { + return $this->access->legacyWriteAllowed($actor); + } + + return $this->access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category) + && $this->access->operational($actor, 'publish', $record->id) && ! $this->access->conflict($actor, $record) + && (int) data_get($record->workflow_context, 'approval.actor_id') !== $actor->id + && (! data_get($record->workflow_context, 'publication_assignment.assignee_user_id') + || (int) data_get($record->workflow_context, 'publication_assignment.assignee_user_id') === $actor->id); + } + + private function authorize(User $actor, Opportunity $record): void + { + abort_unless($this->allowed($actor, $record), 403); + if ($record->owner_user_id !== null) { + app(OpportunityProfileWorkflow::class)->authorizeEditorialCuration($actor, $record); + } + } + + /** @return array */ + public function state(User $actor, int $id, int $page = 1): array + { + $record = Opportunity::findOrFail($id); + $this->authorize($actor, $record); + $history = DB::table('audit_events')->where('source_system', 'opportunity-curation')->where('after_data->record_id', $id) + ->orderByDesc('id')->paginate(25, ['id', 'actor_user_id', 'occurred_at', 'after_data'], 'page', $page)->through(function ($row) { + $data = json_decode($row->after_data, true, 512, JSON_THROW_ON_ERROR); + + return ['id' => $row->id, 'actor' => $row->actor_user_id, 'at' => $row->occurred_at, + 'featured' => $data['featured'], 'reason' => Crypt::decryptString($data['reason'])]; + }); + + return ['record' => ['id' => $record->id, 'title' => $record->title, 'featured' => $record->is_featured], + 'etag' => $this->management->etag($record), 'history' => $history]; + } + + /** @param array $input + * @return array + */ + public function preview(User $actor, int $id, array $input): array + { + $data = $this->validate($input); + $record = Opportunity::findOrFail($id); + $this->authorize($actor, $record); + $this->check($record, $data); + + return ['hash' => $this->hash($actor, $id, $data), 'title' => $record->title, + 'before' => $record->is_featured, 'featured' => $data['featured'], 'reason' => $data['reason']]; + } + + /** @param array $input + * @return array + */ + public function confirm(User $actor, int $id, array $input): array + { + $data = $this->validate($input); + Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted'])->validate(); + abort_unless(hash_equals($this->hash($actor, $id, $data), $input['hash']), 409, 'A conferência mudou; prepare nova prévia.'); + + return DB::transaction(function () use ($actor, $id, $data) { + $record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail(); + $this->authorize($actor, $record); + $receipt = DB::table('audit_events')->where('source_system', 'opportunity-curation')->where('after_data->record_id', $id) + ->where('after_data->client_key', $data['client_key'])->first(['actor_user_id', 'after_data']); + $intent = $this->hash($actor, $id, $data); + if ($receipt) { + $prior = json_decode($receipt->after_data, true, 512, JSON_THROW_ON_ERROR); + abort_unless((int) $receipt->actor_user_id === $actor->id && hash_equals($prior['intent'], $intent), 409, 'Chave já utilizada com outro conteúdo.'); + + return ['replayed' => true, 'featured' => $record->is_featured, 'etag' => $this->management->etag($record)]; + } + $this->check($record, $data); + $before = $record->is_featured; + $record->management_version++; + PortalAudit::save($record, ['is_featured' => $data['featured']], $actor->id); + DB::table('audit_events')->insert(['source_system' => 'opportunity-curation', 'entity_type' => 'opportunities', + 'actor_user_id' => $actor->id, 'action' => 'featured_changed', 'data_classification' => 'restricted', + 'before_data' => json_encode(['record_id' => $id, 'featured' => $before], JSON_THROW_ON_ERROR), + 'after_data' => json_encode(['record_id' => $id, 'featured' => $record->is_featured, 'version' => $record->management_version, + 'reason' => Crypt::encryptString($data['reason']), 'client_key' => $data['client_key'], 'intent' => $intent], JSON_THROW_ON_ERROR)]); + + return ['replayed' => false, 'featured' => $record->is_featured, 'etag' => $this->management->etag($record)]; + }); + } + + /** @param array $input + * @return array{etag:string,client_key:string,featured:bool,reason:string} + */ + private function validate(array $input): array + { + $data = Validator::make($input, ['etag' => 'required|string|size:64', 'client_key' => 'required|uuid', + 'featured' => 'required|boolean', 'reason' => 'required|string|min:20|max:1000'])->validate(); + + return ['etag' => $data['etag'], 'client_key' => $data['client_key'], + 'featured' => (bool) $data['featured'], 'reason' => $data['reason']]; + } + + /** @param array{etag:string,client_key:string,featured:bool,reason:string} $data */ + private function check(Opportunity $record, array $data): void + { + abort_unless(hash_equals($this->management->etag($record), $data['etag']), 409, 'A oportunidade mudou; recupere a versão actual antes de confirmar.'); + abort_if($record->is_featured === $data['featured'], 422, 'O estado de destaque já corresponde à escolha.'); + if ($data['featured']) { + abort_unless(Opportunity::published()->whereKey($record->id)->exists(), 409, 'Só uma oportunidade actualmente pública pode entrar em destaque.'); + } + } + + /** @param array{etag:string,client_key:string,featured:bool,reason:string} $data */ + private function hash(User $actor, int $id, array $data): string + { + return hash_hmac('sha256', json_encode([$actor->id, $id, $data], JSON_THROW_ON_ERROR), (string) config('app.key')); + } +} diff --git a/app/Services/OpportunityProfileWorkflow.php b/app/Services/OpportunityProfileWorkflow.php index 0554e0a0..d17cff9d 100644 --- a/app/Services/OpportunityProfileWorkflow.php +++ b/app/Services/OpportunityProfileWorkflow.php @@ -16,6 +16,11 @@ class OpportunityProfileWorkflow public function __construct(private PortalProfileAccess $access, private OpportunityManagement $management, private OpportunityCanonicalContract $contract, private OpportunityWorkflowTasks $tasks) {} + public function authorizeEditorialCuration(User $actor, Opportunity $record): void + { + $this->authorizeAction($actor, $record, 'publish'); + } + /** @return list */ public function actions(User $actor, Opportunity $record): array { diff --git a/app/Services/PortalProfileAccess.php b/app/Services/PortalProfileAccess.php index ee81b574..dae2bdbe 100644 --- a/app/Services/PortalProfileAccess.php +++ b/app/Services/PortalProfileAccess.php @@ -22,6 +22,8 @@ class PortalProfileAccess app_path('Services/PortalProfileAdministration.php'), app_path('Services/PortalSuperusers.php'), app_path('Services/OpportunityProfileWorkflow.php'), app_path('Http/Controllers/PortalProfileController.php'), app_path('Http/Controllers/DashboardController.php'), app_path('Http/Controllers/OpportunityManagementController.php'), + app_path('Services/OpportunityCuration.php'), app_path('Http/Controllers/OpportunityCurationController.php'), + resource_path('js/pages/management/opportunity-curation.tsx'), app_path('Services/OpportunityCanonicalContract.php'), app_path('Services/OpportunityCanonicalResponse.php'), app_path('Services/OpportunityCategoryFields.php'), app_path('Services/OpportunityModularProfiles.php'), app_path('Services/OpportunityWorkflowTasks.php'), base_path('composer.json'), base_path('composer.lock'), app_path('Services/OpportunityReviewMateriality.php'), app_path('Services/OpportunityReviewConditions.php'), app_path('Services/OpportunityCanonicalMetadata.php'), app_path('Services/OpportunityCompleteness.php'), app_path('Services/OpportunityCanonicalValue.php'), resource_path('data/investmoz-opportunity-modular.schema.json'), resource_path('js/components/opportunity-canonical-fields.tsx'), resource_path('js/components/opportunity-parcel-geometry.tsx'), diff --git a/resources/js/pages/management/opportunity-curation.tsx b/resources/js/pages/management/opportunity-curation.tsx new file mode 100644 index 00000000..22efb38d --- /dev/null +++ b/resources/js/pages/management/opportunity-curation.tsx @@ -0,0 +1,284 @@ +import { Link, router } from '@inertiajs/react'; +import { useEffect, useRef, useState } from 'react'; +import { createOperationKey } from '@/lib/operation-key'; +import { profileJson, ProfileValidationError } from '@/lib/profile-json'; +import { Management, button, control } from './shared'; +import type { Paginated } from './shared'; + +type Preview = { + hash: string; + title: string; + before: boolean; + featured: boolean; + reason: string; +}; + +export default function OpportunityCuration({ + record, + etag: initialEtag, + history, + basePath, +}: { + record: { id: number; title: string; featured: boolean }; + etag: string; + history: Paginated<{ + id: number; + actor: number; + at: string; + featured: boolean; + reason: string; + }>; + basePath: string; +}) { + const [featured, setFeatured] = useState(!record.featured); + const [currentFeatured, setCurrentFeatured] = useState(record.featured); + const [reason, setReason] = useState(''); + const [etag, setEtag] = useState(initialEtag); + const [key, setKey] = useState(createOperationKey); + const [preview, setPreview] = useState(null); + const [confirmed, setConfirmed] = useState(false); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(''); + const [errors, setErrors] = useState>({}); + const inFlight = useRef(false); + const epoch = useRef(0); + const seenRecord = useRef(record.id); + const feedback = useRef(null); + const endpoint = `${basePath}/${record.id}/destaque`; + + useEffect(() => { + if (seenRecord.current !== record.id) { + setReason(''); + setMessage( + 'Foi aberta outra oportunidade; prepare uma decisão própria.', + ); + } else if (etag !== initialEtag) { + setMessage( + 'A versão observada mudou; o fundamento foi conservado e precisa de nova prévia.', + ); + } + seenRecord.current = record.id; + epoch.current++; + inFlight.current = false; + setBusy(false); + setEtag(initialEtag); + setFeatured(!record.featured); + setCurrentFeatured(record.featured); + setPreview(null); + setConfirmed(false); + setKey(createOperationKey()); + }, [record.id, initialEtag, record.featured]); + + function invalidate() { + setPreview(null); + setConfirmed(false); + setKey(createOperationKey()); + } + + async function send(confirm: boolean) { + if (inFlight.current || (confirm && (!preview || !confirmed))) return; + inFlight.current = true; + setBusy(true); + setErrors({}); + setMessage(''); + const currentEpoch = epoch.current; + try { + const result = await profileJson( + `${endpoint}/${confirm ? 'confirmar' : 'previa'}`, + { + etag, + client_key: key, + featured, + reason, + ...(confirm + ? { hash: preview?.hash, confirm: confirmed } + : {}), + }, + ); + if (epoch.current !== currentEpoch) return; + if (confirm) { + setPreview(null); + setConfirmed(false); + setEtag(result.etag); + setCurrentFeatured(result.featured); + setFeatured(!result.featured); + setKey(createOperationKey()); + setReason(''); + setMessage( + result.replayed + ? 'Decisão já registada. O estado actual é recuperado.' + : 'Decisão de destaque registada.', + ); + router.reload({ + only: ['record', 'etag', 'history'], + }); + } else { + setPreview(result); + setConfirmed(false); + } + } catch (error) { + if (epoch.current !== currentEpoch) return; + setMessage( + error instanceof Error + ? error.message + : 'Não foi possível concluir. Repita sem editar para recuperar a decisão.', + ); + if (error instanceof ProfileValidationError) + setErrors(error.fields); + } finally { + if (epoch.current === currentEpoch) { + inFlight.current = false; + setBusy(false); + feedback.current?.focus(); + } + } + } + + return ( + +

{record.title}

+

+ Estado actual:{' '} + {currentFeatured ? 'Em destaque' : 'Sem destaque'}. A curadoria + não publica uma oportunidade nem aprova o investimento. +

+
+ {message} +
+
+ Decisão editorial individual + +