feat(opportunities): curate highlights with native authority and audited confirmation
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\OpportunityCuration;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class OpportunityCurationController extends Controller
|
||||
{
|
||||
public function show(Request $request, int $id, OpportunityCuration $service): Response
|
||||
{
|
||||
$base = str_starts_with($request->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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class OpportunityCuration
|
||||
{
|
||||
public function __construct(private PortalProfileAccess $access, private OpportunityManagement $management) {}
|
||||
|
||||
public function allowed(User $actor, Opportunity $record): bool
|
||||
{
|
||||
if (! $this->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<string,mixed> */
|
||||
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<string,mixed> $input
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $input
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $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'));
|
||||
}
|
||||
}
|
||||
@@ -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<string> */
|
||||
public function actions(User $actor, Opportunity $record): array
|
||||
{
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<Preview | null>(null);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const inFlight = useRef(false);
|
||||
const epoch = useRef(0);
|
||||
const seenRecord = useRef(record.id);
|
||||
const feedback = useRef<HTMLDivElement>(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 (
|
||||
<Management title="Curadoria de destaque">
|
||||
<h1 className="text-2xl font-bold">{record.title}</h1>
|
||||
<p>
|
||||
Estado actual:{' '}
|
||||
{currentFeatured ? 'Em destaque' : 'Sem destaque'}. A curadoria
|
||||
não publica uma oportunidade nem aprova o investimento.
|
||||
</p>
|
||||
<div ref={feedback} tabIndex={-1} role="status" aria-live="polite">
|
||||
{message}
|
||||
</div>
|
||||
<fieldset disabled={busy} className="space-y-4 rounded border p-4">
|
||||
<legend>Decisão editorial individual</legend>
|
||||
<label className="block">
|
||||
Escolha
|
||||
<select
|
||||
className={control}
|
||||
value={featured ? 'yes' : 'no'}
|
||||
onChange={(event) => {
|
||||
setFeatured(event.target.value === 'yes');
|
||||
invalidate();
|
||||
}}
|
||||
>
|
||||
<option value="yes">Colocar em destaque</option>
|
||||
<option value="no">Retirar do destaque</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
Fundamento
|
||||
<textarea
|
||||
className={control}
|
||||
value={reason}
|
||||
minLength={20}
|
||||
maxLength={1000}
|
||||
onChange={(event) => {
|
||||
setReason(event.target.value);
|
||||
invalidate();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{Object.entries(errors).map(([field, value]) => (
|
||||
<p key={field} role="alert">
|
||||
{value}
|
||||
</p>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => send(false)}
|
||||
>
|
||||
Conferir prévia
|
||||
</button>
|
||||
{preview && (
|
||||
<section
|
||||
className="space-y-3 rounded border p-4"
|
||||
aria-label="Prévia da decisão editorial"
|
||||
>
|
||||
<h2 className="font-bold">{preview.title}</h2>
|
||||
<p>
|
||||
{preview.before ? 'Em destaque' : 'Sem destaque'} →{' '}
|
||||
{preview.featured ? 'Em destaque' : 'Sem destaque'}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">{preview.reason}</p>
|
||||
<label className="block">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(event) =>
|
||||
setConfirmed(event.target.checked)
|
||||
}
|
||||
/>{' '}
|
||||
Confirmo esta decisão e o fundamento apresentado.
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={!confirmed}
|
||||
onClick={() => send(true)}
|
||||
>
|
||||
Confirmar decisão de destaque
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
<p>
|
||||
Se a resposta se perder, repita a confirmação sem editar os
|
||||
campos.
|
||||
</p>
|
||||
</fieldset>
|
||||
{!busy && (
|
||||
<Link
|
||||
href={`${basePath}/${record.id}/edit`}
|
||||
className="underline"
|
||||
>
|
||||
Voltar à mesma oportunidade
|
||||
</Link>
|
||||
)}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-bold">
|
||||
Histórico de decisões de destaque — página{' '}
|
||||
{history.current_page}
|
||||
</h2>
|
||||
{history.data.length ? (
|
||||
history.data.map((item) => (
|
||||
<article key={item.id} className="rounded border p-3">
|
||||
<p>
|
||||
{item.at} · Responsável {item.actor} ·{' '}
|
||||
{item.featured
|
||||
? 'Em destaque'
|
||||
: 'Destaque retirado'}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">{item.reason}</p>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<p>
|
||||
Ainda sem decisões de curadoria registadas neste
|
||||
percurso.
|
||||
</p>
|
||||
)}
|
||||
{!busy && (
|
||||
<nav
|
||||
aria-label="Páginas do histórico de curadoria"
|
||||
className="flex gap-4"
|
||||
>
|
||||
{history.prev_page_url && (
|
||||
<Link
|
||||
href={history.prev_page_url}
|
||||
preserveState
|
||||
preserveScroll
|
||||
>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{history.next_page_url && (
|
||||
<Link
|
||||
href={history.next_page_url}
|
||||
preserveState
|
||||
preserveScroll
|
||||
>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</section>
|
||||
</Management>
|
||||
);
|
||||
}
|
||||
@@ -97,10 +97,12 @@ export default function OpportunityDraft({
|
||||
publicationPreview,
|
||||
publicationState,
|
||||
promotionHref,
|
||||
curationHref,
|
||||
auth,
|
||||
canEditDraft = false,
|
||||
} = usePage<{
|
||||
promotionHref?: string | null;
|
||||
curationHref?: string | null;
|
||||
basePath?: string;
|
||||
profileMode?: boolean;
|
||||
profileOrganization?: string | null;
|
||||
@@ -306,6 +308,11 @@ export default function OpportunityDraft({
|
||||
}
|
||||
>
|
||||
{!profileMode && <Link href={basePath}>Voltar à gestão</Link>}
|
||||
{curationHref && (
|
||||
<Link className="ml-3 underline" href={curationHref}>
|
||||
Curadoria de destaque
|
||||
</Link>
|
||||
)}
|
||||
{promotionHref && (
|
||||
<Link className="ml-3 underline" href={promotionHref}>
|
||||
Divulgação promocional — Em preparação
|
||||
|
||||
@@ -32,6 +32,7 @@ use App\Http\Controllers\InvestmentTerritoryController;
|
||||
use App\Http\Controllers\InvestorWorkspaceController;
|
||||
use App\Http\Controllers\LegacyContentController;
|
||||
use App\Http\Controllers\OpportunityClassificationController;
|
||||
use App\Http\Controllers\OpportunityCurationController;
|
||||
use App\Http\Controllers\OpportunityManagementController;
|
||||
use App\Http\Controllers\OpportunityOriginReviewController;
|
||||
use App\Http\Controllers\OpportunityPromotionController;
|
||||
@@ -220,6 +221,9 @@ Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(func
|
||||
Route::get('/minhas-oportunidades/{id}/edit', [$profiles, 'edit'])->whereNumber('id');
|
||||
Route::get('/minhas-oportunidades/{id}/view', [$profiles, 'edit'])->whereNumber('id');
|
||||
Route::get('/minhas-oportunidades/{id}/response', [OpportunityManagementController::class, 'canonicalResponse'])->whereNumber('id');
|
||||
Route::get('/minhas-oportunidades/{id}/destaque', [OpportunityCurationController::class, 'show'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
|
||||
Route::post('/minhas-oportunidades/{id}/destaque/previa', [OpportunityCurationController::class, 'preview'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
|
||||
Route::post('/minhas-oportunidades/{id}/destaque/confirmar', [OpportunityCurationController::class, 'confirm'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
|
||||
Route::post('/minhas-oportunidades/drafts', [OpportunityManagementController::class, 'save']);
|
||||
Route::put('/minhas-oportunidades/{id}/draft', [OpportunityManagementController::class, 'save'])->whereNumber('id');
|
||||
Route::get('/minhas-oportunidades/{id}/classification', [OpportunityClassificationController::class, 'index'])->whereNumber('id');
|
||||
@@ -261,6 +265,9 @@ Route::middleware(['auth', 'verified', EnsureScopedPortalAdministration::class])
|
||||
Route::get('opportunities/create', [OpportunityManagementController::class, 'edit']);
|
||||
Route::get('opportunities/{id}/edit', [OpportunityManagementController::class, 'edit'])->whereNumber('id');
|
||||
Route::get('opportunities/{id}/view', [OpportunityManagementController::class, 'edit'])->whereNumber('id');
|
||||
Route::get('opportunities/{id}/destaque', [OpportunityCurationController::class, 'show'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
|
||||
Route::post('opportunities/{id}/destaque/previa', [OpportunityCurationController::class, 'preview'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
|
||||
Route::post('opportunities/{id}/destaque/confirmar', [OpportunityCurationController::class, 'confirm'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
|
||||
Route::post('opportunities/drafts', [OpportunityManagementController::class, 'save']);
|
||||
Route::put('opportunities/{id}/draft', [OpportunityManagementController::class, 'save'])->whereNumber('id');
|
||||
Route::post('opportunities/{id}/lifecycle', [OpportunityManagementController::class, 'lifecycle'])->whereNumber('id');
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\User;
|
||||
use App\Services\OpportunityCanonicalContract;
|
||||
use App\Services\OpportunityCuration;
|
||||
use App\Services\OpportunityManagement;
|
||||
use App\Services\OpportunityProfileWorkflow;
|
||||
use App\Services\OpportunityWorkflowTasks;
|
||||
use App\Services\PortalProfileAccess;
|
||||
use Illuminate\Foundation\Testing\TestCase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Mockery;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class OpportunityCurationAuthorityTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
DB::shouldReceive('connection')->never();
|
||||
}
|
||||
|
||||
public function test_legacy_curation_cannot_restore_a_revoked_or_replaced_legacy_authority(): void
|
||||
{
|
||||
$actor = new User;
|
||||
$actor->id = 17;
|
||||
$record = new Opportunity;
|
||||
$record->owner_user_id = null;
|
||||
$access = Mockery::mock(PortalProfileAccess::class);
|
||||
$access->shouldReceive('canView')->once()->with($actor, $record)->andReturnTrue();
|
||||
$access->shouldReceive('legacyWriteAllowed')->once()->with($actor)->andReturnFalse();
|
||||
$management = Mockery::mock(OpportunityManagement::class);
|
||||
$management->shouldReceive('installed')->once()->andReturnTrue();
|
||||
$this->assertFalse((new OpportunityCuration($access, $management))->allowed($actor, $record));
|
||||
}
|
||||
|
||||
public function test_native_assignment_to_another_publisher_is_not_inferred_away(): void
|
||||
{
|
||||
$actor = new User;
|
||||
$actor->id = 17;
|
||||
$record = new Opportunity;
|
||||
$record->id = 1;
|
||||
$record->owner_user_id = 19;
|
||||
$record->organization_id = null;
|
||||
$record->opportunity_category = 'investment_project';
|
||||
$record->workflow_context = ['approval' => ['actor_id' => 20], 'publication_assignment' => ['assignee_user_id' => 21]];
|
||||
$access = Mockery::mock(PortalProfileAccess::class);
|
||||
$access->shouldReceive('canView')->once()->with($actor, $record)->andReturnTrue();
|
||||
$access->shouldReceive('scoped')->once()->with($actor, 'publisher', null, 'investment_project')->andReturnTrue();
|
||||
$access->shouldReceive('operational')->once()->with($actor, 'publish', 1)->andReturnTrue();
|
||||
$access->shouldReceive('conflict')->once()->with($actor, $record)->andReturnFalse();
|
||||
$management = Mockery::mock(OpportunityManagement::class);
|
||||
$management->shouldReceive('installed')->once()->andReturnTrue();
|
||||
$this->assertFalse((new OpportunityCuration($access, $management))->allowed($actor, $record));
|
||||
}
|
||||
|
||||
public function test_native_editorial_authorisation_reuses_the_existing_fresh_mfa_requirement(): void
|
||||
{
|
||||
$actor = new User;
|
||||
$actor->id = 17;
|
||||
$record = new Opportunity;
|
||||
$record->id = 1;
|
||||
$record->owner_user_id = 19;
|
||||
$record->organization_id = null;
|
||||
$record->opportunity_category = 'investment_project';
|
||||
$access = Mockery::mock(PortalProfileAccess::class);
|
||||
$access->shouldReceive('authorize')->once()->with($actor, 'view', $record);
|
||||
$access->shouldReceive('promoterRecordScope')->once()->with($actor, $record)->andReturnFalse();
|
||||
$access->shouldReceive('scoped')->once()->with($actor, 'publisher', null, 'investment_project')->andReturnTrue();
|
||||
$access->shouldReceive('operational')->once()->with($actor, 'publish', 1)->andReturnTrue();
|
||||
$access->shouldReceive('conflict')->once()->with($actor, $record)->andReturnFalse();
|
||||
$access->shouldReceive('mfa')->once()->with($actor)->andReturnFalse();
|
||||
$flow = new OpportunityProfileWorkflow($access, Mockery::mock(OpportunityManagement::class), Mockery::mock(OpportunityCanonicalContract::class), Mockery::mock(OpportunityWorkflowTasks::class));
|
||||
try {
|
||||
$flow->authorizeEditorialCuration($actor, $record);
|
||||
$this->fail('A stale MFA session was accepted.');
|
||||
} catch (HttpException $error) {
|
||||
$this->assertSame(403, $error->getStatusCode());
|
||||
$this->assertSame('required', $error->getHeaders()['X-Portal-Reauthentication']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { compileFunction } from 'node:vm';
|
||||
import { createRequire } from 'node:module';
|
||||
import ts from 'typescript';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
function nodes(tree) {
|
||||
if (Array.isArray(tree)) return tree.flatMap(nodes);
|
||||
if (!tree || typeof tree !== 'object') return [];
|
||||
return [tree, ...nodes(tree.props?.children)];
|
||||
}
|
||||
function text(tree) {
|
||||
if (typeof tree === 'string') return tree;
|
||||
if (Array.isArray(tree)) return tree.map(text).join('');
|
||||
return tree?.props ? text(tree.props.children) : '';
|
||||
}
|
||||
function harness() {
|
||||
const memory = [], effects = [], calls = [], reloads = [];
|
||||
let cursor = 0, pending = [], dirty = false, serial = 0, handler;
|
||||
let props = { record: { id: 1, title: 'QA first', featured: false }, etag: 'a'.repeat(64), history: { data: [], current_page: 1 }, basePath: '/management/opportunities' };
|
||||
const code = ts.transpileModule(fs.readFileSync(new URL('../../resources/js/pages/management/opportunity-curation.tsx', import.meta.url), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const module = { exports: {} };
|
||||
const mocks = {
|
||||
react: {
|
||||
useState(initial) {
|
||||
const i = cursor++;
|
||||
if (!(i in memory)) memory[i] = typeof initial === 'function' ? initial() : initial;
|
||||
return [memory[i], (value) => {
|
||||
const next = typeof value === 'function' ? value(memory[i]) : value;
|
||||
if (!Object.is(next, memory[i])) { memory[i] = next; dirty = true; }
|
||||
}];
|
||||
},
|
||||
useRef(initial) { const i = cursor++; memory[i] ??= { current: initial }; return memory[i]; },
|
||||
useEffect(effect, dependencies) {
|
||||
const i = cursor++;
|
||||
if (!effects[i] || dependencies.some((value, index) => value !== effects[i][index])) {
|
||||
effects[i] = dependencies; pending.push(effect);
|
||||
}
|
||||
},
|
||||
},
|
||||
'@inertiajs/react': { Link: 'a', router: { reload: (options) => reloads.push(options) } },
|
||||
'@/lib/operation-key': { createOperationKey: () => `aaaaaaaa-aaaa-4aaa-8aaa-${String(++serial).padStart(12, '0')}` },
|
||||
'@/lib/profile-json': {
|
||||
ProfileValidationError: class extends Error {},
|
||||
profileJson: (url, data) => { calls.push({ url, data: structuredClone(data) }); return handler(url, data); },
|
||||
},
|
||||
'./shared': { Management: 'main', button: '', control: '' },
|
||||
};
|
||||
compileFunction(code, ['require', 'module', 'exports'])((name) => mocks[name] ?? require(name), module, module.exports);
|
||||
let tree;
|
||||
function render() {
|
||||
do { dirty = false; cursor = 0; pending = []; tree = module.exports.default(props); for (const effect of pending) effect(); } while (dirty);
|
||||
return tree;
|
||||
}
|
||||
return {
|
||||
calls, reloads, render,
|
||||
setHandler(fn) { handler = fn; },
|
||||
setProps(next) { props = next; return render(); },
|
||||
button(label) { return nodes(render()).find(node => node.type === 'button' && text(node) === label); },
|
||||
input(type) { return nodes(render()).find(node => node.type === type); },
|
||||
get props() { return props; },
|
||||
};
|
||||
}
|
||||
|
||||
test('editing rationale invalidates both the preview and its confirmation without submitting', async () => {
|
||||
const h = harness();
|
||||
h.setHandler(async () => ({ hash: 'b'.repeat(64), title: 'QA first', before: false, featured: true, reason: 'Rationale for QA editorial selection' }));
|
||||
h.input('textarea').props.onChange({ target: { value: 'Rationale for QA editorial selection' } });
|
||||
await h.button('Conferir prévia').props.onClick();
|
||||
assert.equal(h.button('Confirmar decisão de destaque').props.disabled, true);
|
||||
h.input('input').props.onChange({ target: { checked: true } });
|
||||
assert.equal(h.button('Confirmar decisão de destaque').props.disabled, false);
|
||||
h.input('textarea').props.onChange({ target: { value: 'Changed editorial rationale must be reviewed again' } });
|
||||
assert.equal(h.button('Confirmar decisão de destaque'), undefined);
|
||||
assert.equal(h.calls.length, 1);
|
||||
assert.equal(h.calls[0].data.featured, true);
|
||||
});
|
||||
|
||||
test('in-flight preview is single; uncertain confirmation keeps the same intent for recovery', async () => {
|
||||
const h = harness();
|
||||
let release;
|
||||
h.setHandler(() => new Promise(resolve => { release = resolve; }));
|
||||
h.input('textarea').props.onChange({ target: { value: 'Rationale for QA editorial selection' } });
|
||||
const first = h.button('Conferir prévia').props.onClick();
|
||||
await h.button('Conferir prévia').props.onClick();
|
||||
assert.equal(h.calls.length, 1);
|
||||
assert.equal(h.input('fieldset').props.disabled, true);
|
||||
release({ hash: 'b'.repeat(64), title: 'QA first', before: false, featured: true, reason: 'Rationale for QA editorial selection' });
|
||||
await first;
|
||||
h.input('input').props.onChange({ target: { checked: true } });
|
||||
h.setHandler(async () => { throw new Error('Response lost'); });
|
||||
await h.button('Confirmar decisão de destaque').props.onClick();
|
||||
const uncertain = h.calls.at(-1).data;
|
||||
h.setHandler(async () => ({ replayed: true, featured: true, etag: 'c'.repeat(64) }));
|
||||
await h.button('Confirmar decisão de destaque').props.onClick();
|
||||
assert.deepEqual(h.calls.at(-1).data, uncertain);
|
||||
assert.equal(h.reloads.length, 1);
|
||||
assert.deepEqual(h.reloads[0].only, ['record', 'etag', 'history']);
|
||||
assert.match(text(h.render()), /Decisão já registada/);
|
||||
});
|
||||
|
||||
test('a delayed preview cannot be adopted after another record and version arrive', async () => {
|
||||
const h = harness();
|
||||
let release;
|
||||
h.setHandler(() => new Promise(resolve => { release = resolve; }));
|
||||
h.input('textarea').props.onChange({ target: { value: 'Rationale for QA editorial selection' } });
|
||||
const request = h.button('Conferir prévia').props.onClick();
|
||||
h.setProps({ ...h.props, record: { id: 2, title: 'QA second', featured: false }, etag: 'd'.repeat(64) });
|
||||
release({ hash: 'b'.repeat(64), title: 'QA first', before: false, featured: true, reason: 'Old rationale' });
|
||||
await request;
|
||||
assert.equal(h.button('Confirmar decisão de destaque'), undefined);
|
||||
assert.match(text(h.render()), /QA second/);
|
||||
assert.doesNotMatch(text(h.render()), /Old rationale/);
|
||||
assert.equal(h.input('textarea').props.value, '');
|
||||
});
|
||||
|
||||
test('changing only the history page retains the unsubmitted rationale and reviewed decision', async () => {
|
||||
const h = harness();
|
||||
h.setHandler(async () => ({ hash: 'b'.repeat(64), title: 'QA first', before: false, featured: true, reason: 'Reviewed QA rationale retained during paging' }));
|
||||
h.input('textarea').props.onChange({ target: { value: 'Reviewed QA rationale retained during paging' } });
|
||||
await h.button('Conferir prévia').props.onClick();
|
||||
h.input('input').props.onChange({ target: { checked: true } });
|
||||
h.setProps({ ...h.props, history: { data: [], current_page: 2, prev_page_url: '/management/opportunities/1/destaque?page=1' } });
|
||||
assert.equal(h.input('textarea').props.value, 'Reviewed QA rationale retained during paging');
|
||||
assert.equal(h.button('Confirmar decisão de destaque').props.disabled, false);
|
||||
const link = nodes(h.render()).find(node => node.type === 'a' && text(node) === 'Anterior');
|
||||
assert.equal(link.props.preserveState, true);
|
||||
assert.equal(link.props.preserveScroll, true);
|
||||
});
|
||||
Reference in New Issue
Block a user