feat: consolidar desenvolvimentos do portal InvestMoz

This commit is contained in:
2026-11-03 23:32:32 +02:00
parent 1da273f1f3
commit 36c8cadb49
345 changed files with 110714 additions and 2799 deletions
+4
View File
@@ -1,5 +1,9 @@
# Instruções permanentes do projeto InvesteMoz
## Encerramento e reabertura do dossier — decisão do proprietário, 03/11/2026
O proprietário definiu expressamente: «o dossier tem um dono, esse é que deve executar essa acção», em resposta a quem pode encerrar e reabrir o dossier completo. Usar o dono registado no dossier e conservar a verificação do acesso efectivo existente, incluindo a representação actual quando pertence a uma organização. A condição de gestor, analista ou outro representante não substitui por inferência esse dono. Registar a decisão, motivo, versão e histórico. Encerrar ou reabrir o dossier não equivale a decidir/aprovar pedidos, conferir pagamentos ou alterar em cascata estados de documentos e tarefas.
## Percurso adaptativo — decisão expressa da Tempo, 23/10/2026
Carteira/projectos âncora e percurso adaptativo têm diagnósticos distintos, mas fazem parte da mesma experiência e responsabilidade de correcção. Separar o diagnóstico não autoriza adiamento, desligamento do percurso ou perda de contexto entre corredor/localização, oportunidades, associações documentadas a âncoras/clusters, dossier e simulador. A reposição prioritária só se considera demonstrada com causa delimitada e disponibilidade recuperada, reutilizando o inventário e os recibos existentes; melhorias noutro ponto não a comprovam. Consultar os registos de reposição antes de voltar a declarar causa desconhecida ou registos desaparecidos.
@@ -0,0 +1,21 @@
<?php
namespace App\Exceptions;
use Illuminate\Http\JsonResponse;
use RuntimeException;
use Throwable;
final class OpportunityWorkflowUnavailable extends RuntimeException
{
public function __construct(public readonly string $reference, public readonly bool $recorded, Throwable $previous)
{
parent::__construct('Opportunity operation result requires verification.', 0, $previous);
}
public function render(): JsonResponse
{
return response()->json(['message' => 'Não foi possível confirmar o resultado. Consulte o estado da oportunidade e dos recursos antes de repetir. '.($this->recorded ? 'Incidente técnico registado. ' : 'Não foi possível guardar o incidente; conserve esta referência. ').'Referência: '.$this->reference,
'reference' => $this->reference, 'incident_recorded' => $this->recorded], 503)->header('Cache-Control', 'private, no-store');
}
}
@@ -7,6 +7,7 @@ use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use App\Services\OpportunityMaturity;
use App\Services\OpportunityTextSearch;
use App\Services\PublicSearchMetrics;
use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -63,6 +64,7 @@ class OpportunityController extends Controller
$results = $query->orderByDesc('is_featured')
->orderByDesc('published_at')
->paginate($validated['per_page'] ?? 12);
app(PublicSearchMetrics::class)->candidate($request, 'opportunities_api', $results->total(), $validated);
return response()->json($results->withQueryString()->through(fn ($record) => app(InvestmentCatalogue::class)->presentLocation($record)));
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers;
use App\Services\CompanyComparison;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class CompanyComparisonController extends Controller
{
public function index(Request $request, CompanyComparison $service): Response|JsonResponse
{
$answers = $service->answers($request->all());
$data = ['definition' => $service->definition(), 'answers' => $answers, 'result' => $service->evaluate($answers),
'case' => null, 'saved' => null, 'canWrite' => false, 'history' => [], 'historyNext' => null, 'historical' => false, 'workingCopy' => false];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/company-comparison', $data);
}
public function show(Request $request, string $id, CompanyComparison $service): Response|JsonResponse
{
$data = ['definition' => $service->definition(), ...$service->view($request->user(), $id, $request->all())];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/company-comparison', $data);
}
public function save(Request $request, string $id, CompanyComparison $service): JsonResponse
{
return response()->json($service->save($request->user(), $id, $request->all()));
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Services\CompanyPreparation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class CompanyPreparationController extends Controller
{
public function show(Request $request, string $id, CompanyPreparation $service): Response|JsonResponse
{
$data = $service->view($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/company-preparation', $data);
}
public function save(Request $request, string $id, CompanyPreparation $service): JsonResponse
{
return response()->json($service->save($request->user(), $id, $request->all()));
}
}
+64 -26
View File
@@ -10,9 +10,14 @@ use App\Models\Lead;
use App\Models\User;
use App\Services\ConvertInterestToLead;
use App\Services\CountryCatalogue;
use App\Services\CrmNextAction;
use App\Services\CrmTaskCreation;
use App\Services\CrmTaskPreparations;
use App\Services\DeclaredOriginStatistics;
use App\Services\PortalAudit;
use App\Services\VirtualDesk;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -23,6 +28,12 @@ use Inertia\Response;
class CrmController extends Controller
{
private const VERSION_MESSAGES = [
'version.required' => 'Actualize os dados para recuperar a versão deste registo antes de gravar. As suas edições foram conservadas.',
'version.integer' => 'A versão do registo não foi reconhecida. Recupere os dados actuais.',
'version.min' => 'A versão do registo não foi reconhecida. Recupere os dados actuais.',
];
public function investors(Request $request): Response
{
$filters = $request->validate(['q' => ['nullable', 'string', 'max:150']]);
@@ -43,10 +54,12 @@ class CrmController extends Controller
public function saveInvestor(Request $request, Investor $investor): RedirectResponse
{
$data = $request->validate(['legal_name' => ['required', 'string', 'max:200'],
$data = $request->validate(['version' => 'required|integer|min:1', 'legal_name' => ['required', 'string', 'max:200'],
'investor_type' => ['required', Rule::in(['domestic', 'foreign', 'institutional', 'individual'])],
'status' => ['required', Rule::in(['prospect', 'active', 'inactive'])]]);
PortalAudit::save($investor, $data, $request->user()->id);
'status' => ['required', Rule::in(['prospect', 'active', 'inactive'])]], self::VERSION_MESSAGES);
$version = (int) $data['version'];
unset($data['version']);
PortalAudit::save($investor, $data, $request->user()->id, $version);
return back()->with('success', 'Investidor actualizado.');
}
@@ -54,15 +67,17 @@ class CrmController extends Controller
public function saveContact(Request $request, Investor $investor, Contact $contact): RedirectResponse
{
abort_unless(DB::table('investor_contacts')->where('investor_id', $investor->id)->where('contact_id', $contact->id)->exists(), 404);
$data = $request->validate(['full_name' => ['required', 'string', 'max:150'],
'email' => ['required', 'email:rfc', 'max:150'], 'phone' => ['nullable', 'string', 'max:30']]);
$data = $request->validate(['version' => 'required|integer|min:1', 'full_name' => ['required', 'string', 'max:150'],
'email' => ['required', 'email:rfc', 'max:150'], 'phone' => ['nullable', 'string', 'max:30']], self::VERSION_MESSAGES);
$data['email'] = strtolower(trim($data['email']));
DB::transaction(function () use ($contact, $data, $request) {
$version = (int) $data['version'];
unset($data['version']);
DB::transaction(function () use ($contact, $data, $request, $version) {
DB::select('select pg_advisory_xact_lock(170925, hashtext(?))', [$data['email']]);
if (Contact::where('id', '!=', $contact->id)->whereRaw('lower(trim(email)) = ?', [$data['email']])->exists()) {
throw ValidationException::withMessages(['email' => 'Já existe outro contacto com este email. Reveja os registos antes de os fundir.']);
}
PortalAudit::save($contact, $data, $request->user()->id);
PortalAudit::save($contact, $data, $request->user()->id, $version);
});
return back()->with('success', 'Contacto actualizado.');
@@ -82,7 +97,7 @@ class CrmController extends Controller
'owner_user_id' => ['nullable', 'integer', 'exists:users,id'],
'origin' => ['nullable', Rule::in(['unknown', ...array_column(app(CountryCatalogue::class)->all(), 'code')])]]);
$query = Lead::with(['investor:id,legal_name', 'owner:id,name'])
->withCount(['tasks as overdue_tasks_count' => fn ($q) => $q->whereIn('status', ['open', 'in_progress'])->where('due_at', '<', now())]);
->withCount(['tasks as overdue_tasks_count' => fn ($q) => $q->availableForWork()->where('due_at', '<', now())]);
if ($term = $filters['q'] ?? null) {
$query->where(fn ($q) => $q->whereLike('title', "%{$term}%")
->orWhereHas('investor', fn ($investor) => $investor->whereLike('legal_name', "%{$term}%")));
@@ -127,29 +142,37 @@ class CrmController extends Controller
return redirect('/management/crm/leads/'.$lead->id)->with('success', 'Pedido convertido em lead com responsável e próxima acção.');
}
public function show(Lead $lead): Response
public function show(Request $request, Lead $lead): Response
{
$lead->load(['investor', 'owner:id,name', 'tasks' => fn ($q) => $q->orderBy('due_at')]);
$caseIds = $lead->tasks->pluck('desk_case_id')->filter();
$closedCases = $caseIds->isEmpty() ? [] : DB::table('desk_cases')->whereIn('id', $caseIds)->whereNotNull('closed_at')->pluck('id')->all();
foreach ($lead->tasks as $task) {
$task->setAttribute('can_update', ! in_array($task->getAttribute('desk_case_id'), $closedCases, true));
}
$contacts = DB::table('investor_contacts')->join('contacts', 'contacts.id', '=', 'investor_contacts.contact_id')
->where('investor_id', $lead->investor_id)->whereNull('contacts.deleted_at')->get(['contacts.id', 'full_name', 'email', 'phone']);
return Inertia::render('crm/lead', ['lead' => $lead, 'contacts' => $contacts, 'owners' => $this->owners()]);
return Inertia::render('crm/lead', ['lead' => $lead, 'contacts' => $contacts, 'owners' => $this->owners(), 'taskCreation' => app(CrmTaskCreation::class)->context($request->user(), $lead),
'nextAction' => ['future_tasks' => app(CrmNextAction::class)->future($lead)->count()]]);
}
public function update(Request $request, Lead $lead): RedirectResponse
{
$data = $request->validate(['owner_user_id' => ['required', 'integer', Rule::in($this->owners()->pluck('id'))],
$data = $request->validate(['version' => 'required|integer|min:1', 'owner_user_id' => ['required', 'integer', Rule::in($this->owners()->pluck('id'))],
'status' => ['required', Rule::in(['new', 'qualified', 'disqualified'])],
'qualification_reason' => ['required', 'string', 'min:10', 'max:2000']]);
DB::transaction(function () use ($lead, $data, $request) {
'qualification_reason' => ['required', 'string', 'min:10', 'max:2000']], self::VERSION_MESSAGES);
$version = (int) $data['version'];
unset($data['version']);
DB::transaction(function () use ($lead, $data, $request, $version) {
$lead = Lead::lockForUpdate()->findOrFail($lead->id);
if ($lead->status === 'converted') {
throw ValidationException::withMessages(['status' => 'O lead já foi convertido numa negociação.']);
}
if ($data['status'] !== 'disqualified' && ! $lead->tasks()->whereIn('status', ['open', 'in_progress'])->where('due_at', '>', now())->exists()) {
if ($data['status'] !== 'disqualified' && ! app(CrmNextAction::class)->future($lead)->exists()) {
throw ValidationException::withMessages(['status' => 'Registe uma próxima acção com prazo futuro antes de activar ou qualificar o lead.']);
}
PortalAudit::save($lead, $data, $request->user()->id);
PortalAudit::save($lead, $data, $request->user()->id, $version);
});
return back()->with('success', 'Lead actualizado.');
@@ -157,24 +180,39 @@ class CrmController extends Controller
public function task(Request $request, Lead $lead): RedirectResponse
{
$data = $request->validate(['title' => ['required', 'string', 'max:200'], 'due_at' => ['required', 'date', 'after:now'],
'assignee_user_id' => ['required', 'integer', Rule::in($this->owners()->pluck('id'))]]);
PortalAudit::save(new CrmTask, [...$data, 'lead_id' => $lead->id, 'status' => 'open'], $request->user()->id);
$result = app(CrmTaskCreation::class)->create($request->user(), $lead, $request->all(), $this->owners()->pluck('id')->all());
return back()->with('success', 'Tarefa criada.');
return redirect('/management/crm/leads/'.$lead->id)->with('success', $result['replayed'] ? 'A tarefa já tinha sido registada. A criação não foi repetida.' : 'Tarefa criada.');
}
public function taskPreparation(Request $request, Lead $lead): JsonResponse
{
// Preserve the user's partial text; business validation still occurs only on task creation.
$input = json_decode($request->getContent(), true);
if (! is_array($input)) {
throw ValidationException::withMessages(['snapshot' => 'A preparação deve ser enviada em JSON.']);
}
return response()->json(app(CrmTaskPreparations::class)->save($request->user(), $lead, $input));
}
public function updateTask(Request $request, Lead $lead, CrmTask $task): RedirectResponse
{
abort_unless($task->lead_id === $lead->id, 404);
$data = $request->validate(['status' => ['required', Rule::in(['open', 'in_progress', 'done', 'cancelled'])]]);
DB::transaction(function () use ($lead, $task, $data, $request) {
$lead = Lead::lockForUpdate()->findOrFail($lead->id);
if (in_array($lead->status, ['new', 'qualified'], true) && in_array($data['status'], ['done', 'cancelled'], true)
&& ! $lead->tasks()->where('id', '!=', $task->id)->whereIn('status', ['open', 'in_progress'])->where('due_at', '>', now())->exists()) {
throw ValidationException::withMessages(['status' => 'Crie a próxima acção antes de encerrar a última tarefa de um lead activo.']);
$data = $request->validate(['version' => 'required|integer|min:1', 'status' => ['required', Rule::in(['open', 'in_progress', 'done', 'cancelled'])]], self::VERSION_MESSAGES);
$version = (int) $data['version'];
unset($data['version']);
DB::transaction(function () use ($lead, $task, $data, $request, $version) {
if ($task->getAttribute('desk_case_id')) {
$case = DB::table('desk_cases')->where('id', $task->getAttribute('desk_case_id'))->lockForUpdate()->first();
abort_unless($case !== null, 404);
app(VirtualDesk::class)->assertOpen($case);
}
PortalAudit::save($task, $data, $request->user()->id);
$lead = Lead::lockForUpdate()->findOrFail($lead->id);
$task = CrmTask::lockForUpdate()->findOrFail($task->id);
abort_unless($task->lead_id === $lead->id, 404);
app(CrmNextAction::class)->assertMayFinish($lead, $task, $data['status']);
PortalAudit::save($task, $data, $request->user()->id, $version);
});
return back()->with('success', 'Tarefa actualizada.');
+10 -3
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\DashboardWorkQueue;
use App\Services\InvestorPath;
use App\Services\PortalProfileAccess;
use Illuminate\Http\Request;
@@ -16,12 +17,18 @@ class DashboardController extends Controller
{
public function __invoke(Request $request): Response
{
if (! $request->user()->can('manage-portal')) {
$validated = $request->validate(['interests_page' => 'sometimes|integer|min:1', 'desk_page' => 'sometimes|integer|min:1', 'crm_tasks_page' => 'sometimes|integer|min:1']);
$pages = [];
foreach (['interests_page', 'desk_page', 'crm_tasks_page'] as $key) {
$pages[$key] = (int) ($validated[$key] ?? 1);
}
$workQueue = app(DashboardWorkQueue::class)->forActor($request->user(), $pages);
if (! $request->user()->can('manage-portal') || app(PortalProfileAccess::class)->has($request->user(), 'auditor')) {
$path = app(InvestorPath::class);
$contexts = $path->contexts($request->user());
$current = $contexts ? $path->view($request->user(), $contexts[0]['id']) : null;
return Inertia::render('dashboard', ['summary' => null, 'investorPath' => $path->available() ? [
return Inertia::render('dashboard', ['summary' => null, 'workQueue' => $workQueue, 'investorPath' => $path->available() ? [
'contexts' => $contexts,
'current' => $current ? ['id' => $current['case']['id'], 'next' => $current['next'],
'pending' => count(array_filter($current['steps'], fn ($step) => ! in_array($step['state'], ['completed', 'approved', 'dispensed'], true))),
@@ -36,7 +43,7 @@ class DashboardController extends Controller
$drafts = app(PortalProfileAccess::class)->scope($drafts, $request->user());
}
return Inertia::render('dashboard', ['summary' => [
return Inertia::render('dashboard', ['workQueue' => $workQueue, 'summary' => [
'canCreateOpportunity' => app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()),
'requestsByStatus' => collect(['received', 'in_review', 'contacted', 'closed'])
->mapWithKeys(fn ($status) => [$status => (int) ($counts[$status] ?? 0)]),
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Services\DeskRequestCheckpoints;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class DeskRequestCheckpointController extends Controller
{
public function show(Request $request, string $id, DeskRequestCheckpoints $checkpoints): JsonResponse
{
return response()->json($checkpoints->show($request->user(), $id));
}
public function save(Request $request, string $id, DeskRequestCheckpoints $checkpoints): JsonResponse
{
return response()->json($checkpoints->save($request->user(), $id, $request->all()));
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Services\DeskServiceAvailability;
use App\Services\DeskServiceEditor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -13,7 +14,7 @@ final class DeskServiceEditorController extends Controller
{
public function index(Request $request, DeskServiceEditor $editor): Response|JsonResponse
{
$data = ['services' => $editor->listing()];
$data = ['services' => $editor->listing(), 'schedulingAvailable' => app(DeskServiceAvailability::class)->scheduled(), 'today' => now()->toDateString(), 'calendarTimezone' => config('app.timezone')];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/service-editor', $data);
}
@@ -22,8 +23,8 @@ final class DeskServiceEditorController extends Controller
{
$row = $editor->publish($request->user(), $code, $request->all());
return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version], 201)
: back()->with('success', 'Nova versão publicada para pedidos futuros. Os pedidos existentes conservam a sua versão.');
return $request->expectsJson() ? response()->json(['id' => $row->id, 'version' => $row->version, 'effective_from' => $row->effective_from ?? null], 201)
: back()->with('success', 'Versão publicada'.(($row->effective_from ?? null) ? ' com entrada em vigor em '.$row->effective_from : '').'. Os pedidos existentes conservam a sua versão.');
}
public function history(Request $request, DeskServiceEditor $editor, string $code): JsonResponse
@@ -2,28 +2,28 @@
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Services\DocumentManagementAccess;
use App\Services\Malware\DocumentScanRetry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DocumentMalwareController extends Controller
{
public function retry(Request $request, string $id): RedirectResponse
public function retry(Request $request, string $id): RedirectResponse|JsonResponse
{
DB::transaction(function () use ($id, $request): void {
$document = app(DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->lockForUpdate()->first();
abort_unless($document && $document->status !== 'deleted' && $document->deleted_at === null, 404);
abort_unless(in_array($document->malware_scan_status, ['pending', 'inconclusive', 'failed'], true), 422,
'Só é possível repetir uma análise pendente, inconclusiva ou falhada. Para substituir um ficheiro, carregue uma nova versão.');
$result = app(DocumentScanRetry::class)->request($request->user(), $id);
$message = match ($result['outcome']) {
'accepted' => 'Pedido de análise aceite pelo serviço de envio. Acompanhe o estado do ficheiro até haver resultado.',
'duplicate' => 'Já existe uma reserva de análise para este ficheiro. Não foi enviado outro pedido. Acompanhe o estado antes de repetir.',
'stale' => 'O ficheiro ou o seu acesso mudou. Actualize a página para consultar o estado antes de repetir.',
'unconfirmed' => 'Não foi possível confirmar o envio da análise. Consulte o estado antes de repetir. Referência: '.$result['reference'],
default => 'Pedido preparado para envio após a confirmação da gravação. Acompanhe o estado do ficheiro.',
};
DB::table('documents')->where('id', $id)->update([
'status' => 'quarantined', 'malware_scan_status' => 'pending', 'malware_failure_reason' => null, 'updated_at' => now(),
]);
ScanDocumentForMalware::dispatch($id)->afterCommit();
});
if ($request->expectsJson()) {
return response()->json([...$result, 'message' => $message], $result['outcome'] === 'unconfirmed' ? 503 : 200)->header('Cache-Control', 'no-store, private');
}
return back()->with('success', 'Nova análise colocada na fila. O ficheiro permanece privado até haver resultado.');
return back()->with($result['outcome'] === 'accepted' ? 'success' : 'warning', $message);
}
}
+101 -44
View File
@@ -11,11 +11,19 @@ use App\Models\Sector;
use App\Models\Skill;
use App\Models\TrainingCapacity;
use App\Models\TrainingProvider;
use App\Models\User;
use App\Services\EducationAccess;
use App\Services\EducationDeclarationDrafts;
use App\Services\EducationDeclarationRules;
use App\Services\EducationGapCalculator;
use App\Services\EducationReviews;
use App\Services\EducationWorkQueue;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
@@ -23,16 +31,23 @@ use Inertia\Response;
class EducationController extends Controller
{
public function index(Request $request): Response
public function index(Request $request): \Symfony\Component\HttpFoundation\Response
{
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(['submitted', 'reviewed', 'rejected', 'expired'])],
'geo_location_id' => ['nullable', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
'demand_page' => ['nullable', 'integer', 'min:1'],
'capacity_page' => ['nullable', 'integer', 'min:1'],
]);
app(EducationAccess::class)->authorize($request->user());
$filters = $this->filters($request);
if (($filters['scope'] ?? 'all') === 'review') {
unset($filters['status']);
}
$draftService = app(EducationDeclarationDrafts::class);
$draftsAvailable = $draftService->installed();
$selectedDraft = null;
if (! empty($filters['draft'])) {
abort_unless($draftsAvailable, 404);
$kind = DB::table('education_declaration_drafts')->where('id', $filters['draft'])->where('owner_user_id', $request->user()->id)->value('kind');
abort_unless($kind !== null, 404);
$selectedDraft = $draftService->show($request->user(), $kind, $filters['draft']);
}
Inertia::encryptHistory();
$demands = LabourDemand::query();
$capacities = TrainingCapacity::query();
$territoryIds = [];
@@ -53,20 +68,36 @@ class EducationController extends Controller
}
}
// Viewing one's work must not remove existing territorial catalogue evidence.
$territorialCapacities = clone $capacities;
foreach ([$demands, $capacities] as $query) {
if (($filters['scope'] ?? 'all') === 'mine') {
$query->where('submitted_by', $request->user()->id);
} elseif (($filters['scope'] ?? 'all') === 'review') {
$query->where('submitted_by', '<>', $request->user()->id)->where('status', 'submitted')->where('valid_until', '>=', now()->toDateString());
}
}
$providers = TrainingProvider::where('status', '!=', 'inactive');
$courses = Course::where('status', '!=', 'retired')
->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id'));
if ($territoryIds !== []) {
// Provider and course location is evidenced by their submitted training capacity.
// Do not infer locations from provider names, addresses, or unrelated records.
$courses->whereIn('id', $territorialCapacities->select('course_id'));
$providers->whereIn('id', (clone $courses)->select('provider_id'));
}
return Inertia::render('education/index', [
'demands' => $demands->latest()->orderBy('id')->paginate(20, ['*'], 'demand_page')->withQueryString(),
'capacities' => $capacities->latest()->orderBy('id')->paginate(20, ['*'], 'capacity_page')->withQueryString(),
return tap(Inertia::render('education/index', [
'workQueue' => app(EducationWorkQueue::class)->summary($request->user()),
'draftsAvailable' => $draftsAvailable, 'selectedDraft' => $selectedDraft,
'ownDrafts' => $draftsAvailable ? DB::table('education_declaration_drafts')->where('owner_user_id', $request->user()->id)
->orderByDesc('updated_at')->orderBy('id')->paginate(20, ['id', 'kind', 'version_no', 'state', 'updated_at', 'payload'], 'draft_page')->withQueryString()
->through(function ($row) {
$payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR);
return ['id' => $row->id, 'kind' => $row->kind, 'version_no' => (int) $row->version_no, 'state' => $row->state,
'updated_at' => CarbonImmutable::parse($row->updated_at)->toIso8601String(), 'label' => $payload['qualification'] ?? null];
}) : null,
'demands' => $demands->latest()->orderBy('id')->paginate(20, ['*'], 'demand_page')->withQueryString()->through(fn (LabourDemand $row) => $this->listing($row, 'demands', $request, $filters)),
'capacities' => $capacities->latest()->orderBy('id')->paginate(20, ['*'], 'capacity_page')->withQueryString()->through(fn (TrainingCapacity $row) => $this->listing($row, 'capacities', $request, $filters)),
'filters' => $filters,
'indicators' => [
'demand_records' => LabourDemand::count(),
@@ -83,9 +114,62 @@ class EducationController extends Controller
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])
->orderBy('name')->get(['id', 'name', 'parent_id', 'location_type']),
'today' => now()->toDateString(),
])->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
/** @return array<string,mixed> */
private function filters(Request $request): array
{
return $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(['submitted', 'reviewed', 'rejected', 'expired'])],
'geo_location_id' => ['nullable', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
'demand_page' => ['nullable', 'integer', 'min:1'],
'capacity_page' => ['nullable', 'integer', 'min:1'],
'draft' => ['nullable', 'uuid'], 'draft_page' => ['nullable', 'integer', 'min:1'],
'scope' => ['nullable', Rule::in(['all', 'mine', 'review'])],
]);
}
/** @param array<string,mixed> $filters
* @return array<string,mixed> */
private function listing(LabourDemand|TrainingCapacity $record, string $kind, Request $request, array $filters): array
{
$unavailable = $record->getAttribute('valid_until') < now()->toDateString() ? 'expired' : ((int) $record->getAttribute('submitted_by') === $request->user()->id ? 'self' : null);
return [...$record->toArray(), 'can_review' => $unavailable === null, 'review_unavailable' => $unavailable,
'details_url' => route('management.education.declaration', ['kind' => $kind, 'id' => $record->id, ...$filters], false)];
}
public function declaration(Request $request, string $kind, string $id): \Symfony\Component\HttpFoundation\Response
{
app(EducationAccess::class)->authorize($request->user());
$model = match ($kind) {
'demands' => LabourDemand::class,'capacities' => TrainingCapacity::class,default => abort(404)
};
$record = $model::findOrFail($id);
$filters = $this->filters($request);
$request->validate(['history_page' => ['nullable', 'integer', 'min:1']]);
$row = $this->listing($record, $kind, $request, $filters);
$row['review_context'] = app(EducationReviews::class)->context($request->user(), $record);
$row['submitted_by_name'] = User::whereKey($record->getAttribute('submitted_by'))->value('name');
$row['reviewed_by_name'] = $record->getAttribute('reviewed_by') ? User::whereKey($record->getAttribute('reviewed_by'))->value('name') : null;
$references = ['occupation' => Occupation::whereKey($record->getAttribute('occupation_id'))->value('name'),
'skill' => Skill::whereKey($record->getAttribute('skill_id'))->value('name'),
'location' => collect(app(InvestmentGeography::class)->path($record->getAttribute('geo_location_id'), true))->pluck('name')->implode(' › ')];
if ($kind === 'demands') {
$references['project'] = Project::whereKey($record->getAttribute('project_id'))->value('name');
} else {
$references['course'] = Course::whereKey($record->getAttribute('course_id'))->value('title');
}
Inertia::encryptHistory();
return tap(Inertia::render('education/declaration', ['declaration' => $row, 'kind' => $kind, 'actorId' => $request->user()->id, 'today' => now()->toDateString(),
'history' => app(EducationReviews::class)->history($request->user(), $record),
'backUrl' => route('management.education.index', $filters, false), 'references' => $references])->toResponse($request), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function catalog(Request $request, string $kind): RedirectResponse
{
$model = match ($kind) {
@@ -112,24 +196,7 @@ class EducationController extends Controller
public function store(Request $request, string $kind): RedirectResponse
{
$demand = $kind === 'demands';
$data = $request->validate([
'occupation_id' => ['required', 'uuid', Rule::exists('occupations', 'id')->whereNull('deleted_at')],
'skill_id' => ['required', 'uuid', Rule::exists('skills', 'id')->whereNull('deleted_at')],
'geo_location_id' => ['required', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])],
$demand ? 'project_id' : 'course_id' => ['required', 'uuid', $demand
? Rule::exists('projects', 'id')->whereNull('deleted_at')
: Rule::exists('courses', 'id')->whereNull('deleted_at')->whereNot('status', 'retired')
->where(fn ($query) => $query->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id')))],
'employer' => [$demand ? 'required' : 'exclude', 'string', 'max:200'],
'qualification' => ['required', 'string', 'max:150'],
'quantity' => ['required', 'integer', 'min:1', 'max:10000000'],
'period_start' => ['required', 'date_format:Y-m-d'],
'period_end' => ['required', 'date_format:Y-m-d', 'after_or_equal:period_start'],
'source' => ['required', 'string', 'min:10', 'max:5000'],
'reference_date' => ['required', 'date_format:Y-m-d', 'before_or_equal:today'],
'valid_until' => ['required', 'date_format:Y-m-d', 'after_or_equal:reference_date'],
]);
$data = $request->validate(EducationDeclarationRules::forKind($kind));
PortalAudit::save($demand ? new LabourDemand : new TrainingCapacity,
[...$data, 'submitted_by' => $request->user()->id, 'status' => 'submitted'], $request->user()->id);
@@ -138,19 +205,9 @@ class EducationController extends Controller
public function review(Request $request, string $kind, string $id): RedirectResponse
{
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'],
'status' => ['required', 'in:reviewed,rejected'], 'review_reason' => ['required', 'string', 'min:15', 'max:3000']]);
$model = $kind === 'demands' ? LabourDemand::class : TrainingCapacity::class;
DB::transaction(function () use ($model, $id, $data, $request) {
$record = $model::whereKey($id)->lockForUpdate()->firstOrFail();
abort_if((int) $record->getAttribute('submitted_by') === $request->user()->id, 403, 'Outro gestor deve efectuar a revisão.');
abort_if((int) $record->getAttribute('version_no') !== $data['version_no'], 409, 'A versão foi alterada. Recarregue antes de decidir.');
abort_if($record->getAttribute('valid_until') < now()->toDateString(), 422, 'A fonte expirou.');
PortalAudit::save($record, [...$data, 'version_no' => $data['version_no'] + 1,
'reviewed_by' => $request->user()->id], $request->user()->id);
});
$result = app(EducationReviews::class)->decide($request->user(), $kind, $id, $request->all());
return back()->with('success', 'Revisão interna registada; não substitui decisão institucional.');
return back()->with('success', $result['replayed'] ? 'A revisão já tinha sido registada.' : 'Revisão interna registada; não substitui decisão institucional.');
}
public function analysis(string $id, EducationGapCalculator $calculator): Response
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use App\Services\EducationDeclarationDrafts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class EducationDeclarationDraftController extends Controller
{
public function index(Request $request, string $kind, EducationDeclarationDrafts $drafts): JsonResponse
{
$drafts->guard($request->user(), $kind);
return response()->json(DB::table('education_declaration_drafts')->where('owner_user_id', $request->user()->id)->where('kind', $kind)
->orderByDesc('updated_at')->orderBy('id')->paginate(20, ['id', 'kind', 'version_no', 'state', 'updated_at']))->header('Cache-Control', 'private, no-store');
}
public function show(Request $request, string $kind, string $id, EducationDeclarationDrafts $drafts): JsonResponse
{
return response()->json($drafts->show($request->user(), $kind, $id))->header('Cache-Control', 'private, no-store');
}
public function save(Request $request, string $kind, EducationDeclarationDrafts $drafts, ?string $id = null): JsonResponse
{
return response()->json($drafts->save($request->user(), $kind, $id, $request->all()))->header('Cache-Control', 'private, no-store');
}
public function submit(Request $request, string $kind, string $id, EducationDeclarationDrafts $drafts): JsonResponse
{
return response()->json($drafts->submit($request->user(), $kind, $id, $request->all()))->header('Cache-Control', 'private, no-store');
}
}
@@ -7,6 +7,7 @@ use App\Models\ExistingBusinessObject;
use App\Models\Sector;
use App\Policies\ExistingBusinessPolicy;
use App\Services\BusinessFormSchema;
use App\Services\BusinessPointPublication;
use App\Services\CountryCatalogue;
use App\Services\ExistingBusinessDrafts;
use App\Services\ExistingBusinessIntake;
@@ -69,6 +70,7 @@ class ExistingBusinessController extends Controller
Gate::authorize('view', $business);
app(CompatibilityShadow::class)->schedule($id);
$props = ['countries' => app(CountryCatalogue::class)->all(), 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'formSchema' => BusinessFormSchema::fields(), 'modalityFormType' => ExistingBusinessProfileCategory::formType($business->modality), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business),
'pointPublicationAvailable' => app(BusinessPointPublication::class)->available(), 'registeredLocation' => app(BusinessPointPublication::class)->registered($business),
'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business),
'canTriage' => $request->user()->can('triage', $business),
'canApproveLevelOne' => $request->user()->can('approveLevelOne', $business),
@@ -12,6 +12,7 @@ use App\Services\InvestmentGeography;
use App\Services\InvestmentZones;
use App\Services\OpportunityCorridorExplorer;
use App\Services\OpportunityTextSearch;
use App\Services\PublicSearchMetrics;
use App\Services\SectorInvestment;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
@@ -38,6 +39,7 @@ class InvestmentDiscoveryController extends Controller
'href' => ($request->routeIs('investment.education') ? '/educacao' : '/oportunidades').'?'.http_build_query($relatedFilters),
'items' => $catalogue->query($relatedFilters)->limit(6)->get()->map(fn ($record) => $catalogue->presentLocation($record)),
];
app(PublicSearchMetrics::class)->candidate($request, $request->routeIs('investment.education') ? 'education_page' : 'opportunities_page', $results->total(), $request->routeIs('investment.education') ? Arr::except($filters, ['education']) : $filters);
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters),
'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(OpportunityCorridorExplorer::class)->groups($filters) : [],
@@ -88,7 +90,11 @@ class InvestmentDiscoveryController extends Controller
public function search(Request $request, InvestmentCatalogue $catalogue): JsonResponse
{
return response()->json($catalogue->query($catalogue->filters($request))->paginate($request->integer('per_page', 12))->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record)));
$filters = $catalogue->filters($request);
$results = $catalogue->query($filters)->paginate($request->integer('per_page', 12))->withQueryString()->through(fn ($record) => $catalogue->presentLocation($record));
app(PublicSearchMetrics::class)->candidate($request, 'opportunities_api', $results->total(), $filters);
return response()->json($results);
}
public function references(Request $request, InvestmentCatalogue $catalogue): JsonResponse
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentGuides;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class InvestmentGuideController extends Controller
{
public function index(Request $request, string $slug, InvestmentGuides $guides): Response|JsonResponse
{
$data = $guides->publicView($slug, $request->all());
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/guide', $data);
}
public function show(Request $request, string $id, string $slug, InvestmentGuides $guides): Response|JsonResponse
{
$data = $guides->view($request->user(), $id, $slug, $request->all());
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/guide', $data);
}
public function save(Request $request, string $id, string $slug, InvestmentGuides $guides): JsonResponse
{
return response()->json($guides->save($request->user(), $id, $slug, $request->all()));
}
}
@@ -13,6 +13,7 @@ use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations;
use App\Services\InvestmentZones;
use App\Services\PortalAudit;
use App\Services\PublicSearchMetrics;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\RedirectResponse;
@@ -85,7 +86,12 @@ class InvestmentPortfolioController extends Controller
app(InvestmentGeography::class)->constrainDossiersToTerritories($query, $territoryIds);
}
return Inertia::render('portfolio/index', ['records' => $query->orderBy('title')->paginate(30)->withQueryString(),
$results = $query->orderBy('title')->paginate(30)->withQueryString();
if (! $management) {
app(PublicSearchMetrics::class)->candidate($request, 'portfolio', $results->total(), $filters);
}
return Inertia::render('portfolio/index', ['records' => $results,
'management' => $management, 'filters' => $filters, 'sectors' => $sectorOptions, 'territorySelection' => $territorySelection]);
}
@@ -10,6 +10,7 @@ use App\Services\CountryCatalogue;
use App\Services\FundingProgrammes;
use App\Services\InvestmentGeography;
use App\Services\InvestmentOpportunityAlerts;
use App\Services\InvestmentProfileCommands;
use App\Services\InvestmentWorkspace;
use App\Services\OpportunityManagement;
use App\Services\OrganizationCountry;
@@ -25,9 +26,9 @@ use Inertia\Response;
class InvestmentWorkspaceController extends Controller
{
public function profile(Request $request, InvestmentWorkspace $workspace): Response|JsonResponse
public function profile(Request $request, InvestmentWorkspace $workspace): \Symfony\Component\HttpFoundation\Response
{
$data = ['organizationCountryAvailable' => app(OrganizationCountry::class)->available(), 'organizations' => app(OrganizationCountry::class)->represented($request->user()), 'countries' => app(CountryCatalogue::class)->all(), 'account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations')
$data = ['actorId' => $request->user()->id, 'organizationCountryAvailable' => app(OrganizationCountry::class)->available(), 'organizations' => app(OrganizationCountry::class)->represented($request->user()), 'countries' => app(CountryCatalogue::class)->all(), 'account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations')
->where('user_id', $request->user()->id)->orderByDesc('created_at')->get(['id', 'version', 'payload'])->map(function ($company) {
$company->payload = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
$company->countrySource = $company->payload['organization_country_snapshot'] ?? null;
@@ -35,12 +36,17 @@ class InvestmentWorkspaceController extends Controller
return $company;
})];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/profile', $data);
Inertia::encryptHistory();
$response = $request->expectsJson() ? response()->json($data) : Inertia::render('investment/profile', $data)->toResponse($request);
$response->headers->set('Cache-Control', 'no-store, private');
return $response;
}
public function saveProfile(Request $request): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer', 'min:0'],
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'operation_key' => ['sometimes', 'required', 'uuid'],
'payload' => ['required', 'array:phone,country,residence_country_code,sector,experience,investment_capacity'],
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => ['nullable', 'string', 'max:100'],
@@ -48,12 +54,19 @@ class InvestmentWorkspaceController extends Controller
'payload.investment_capacity' => ['nullable', 'string', 'max:1000']]);
$version = DB::transaction(function () use ($request, $data) {
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->firstOrFail();
$commands = app(InvestmentProfileCommands::class);
$command = ['kind' => 'account', 'target_id' => null, 'version' => (int) $data['version'], 'payload' => $data['payload']];
$replay = $commands->replay($request->user(), $data['operation_key'] ?? null, $command);
if ($replay !== null) {
return $replay['version'];
}
$row = DB::table('investment_accounts')->where('user_id', $request->user()->id)->first();
abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'Perfil alterado. Recarregue.');
$version = (int) $data['version'] + 1;
DB::table('investment_accounts')->updateOrInsert(['user_id' => $request->user()->id], ['version' => $version,
'payload' => Crypt::encryptString(json_encode($data['payload'], JSON_THROW_ON_ERROR)), 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]);
$this->audit($request, 'profile_saved', 'investment_accounts', null, ['version' => $version]);
$commands->record($request->user(), $data['operation_key'] ?? null, $command, ['version' => $version]);
return $version;
});
@@ -63,7 +76,7 @@ class InvestmentWorkspaceController extends Controller
public function company(Request $request, ?string $id = null): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer', 'min:0'],
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'operation_key' => ['sometimes', 'required', 'uuid'],
'payload' => ['required', 'array:name,country,incorporation_country_code,registration,sector,role,description'],
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required_without:payload.incorporation_country_code', 'nullable', 'string', 'max:100'],
@@ -73,6 +86,12 @@ class InvestmentWorkspaceController extends Controller
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->firstOrFail();
$row = $id ? DB::table('investment_company_declarations')->where('id', $id)->where('user_id', $request->user()->id)->first() : null;
abort_if($id && ! $row, 404);
$commands = app(InvestmentProfileCommands::class);
$command = ['kind' => 'company', 'target_id' => $id, 'version' => (int) $data['version'], 'payload' => $data['payload']];
$replay = $commands->replay($request->user(), $data['operation_key'] ?? null, $command);
if ($replay !== null) {
return $replay;
}
abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'Declaração alterada. Recarregue.');
abort_if(! $id && DB::table('investment_company_declarations')->where('user_id', $request->user()->id)->count() >= 20, 422, 'Máximo de vinte empresas declaradas.');
$id ??= (string) Str::uuid();
@@ -86,6 +105,7 @@ class InvestmentWorkspaceController extends Controller
DB::table('investment_company_declarations')->updateOrInsert(['id' => $id], ['user_id' => $request->user()->id, 'version' => $version,
'payload' => $encrypted, 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]);
$this->audit($request, 'company_declared', 'investment_company_declarations', $id, ['version' => $version, 'before_payload' => $row?->payload, 'after_payload' => $encrypted]);
$commands->record($request->user(), $data['operation_key'] ?? null, $command, ['id' => $id, 'version' => $version]);
return ['id' => $id, 'version' => $version];
});
@@ -102,13 +122,25 @@ class InvestmentWorkspaceController extends Controller
public function reuseOrganizationCountry(Request $request, string $id, OrganizationCountry $service): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'organization_id' => 'required|uuid', 'organization_version' => 'required|integer|min:1']);
$data = $request->validate(['version' => 'required|integer|min:1', 'organization_id' => 'required|uuid', 'organization_version' => 'required|integer|min:1', 'operation_key' => ['sometimes', 'required', 'uuid']]);
$result = DB::transaction(function () use ($request, $id, $data, $service) {
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->firstOrFail();
$row = DB::table('investment_company_declarations')->where('id', $id)->where('user_id', $request->user()->id)->lockForUpdate()->first();
abort_unless($row !== null, 404);
$snapshot = $service->readableSnapshot($request->user(), $data['organization_id']);
$commands = app(InvestmentProfileCommands::class);
$command = ['kind' => 'organization_country_reuse', 'target_id' => $row->id, 'version' => (int) $data['version'], 'payload' => ['organization_id' => $snapshot['organization_id'], 'organization_version' => (int) $data['organization_version']]];
$replay = $commands->replay($request->user(), $data['operation_key'] ?? null, $command);
if ($replay !== null) {
if (! isset($replay['country'])) {
throw new \LogicException('Stored country reuse receipt unavailable.');
}
return ['version' => $replay['version'], 'country' => $replay['country']];
}
abort_unless((int) $row->version === (int) $data['version'], 409, 'Declaração alterada. Recarregue.');
$snapshot = $service->snapshot($request->user(), $data['organization_id']);
abort_unless((int) $snapshot['organization_version'] === (int) $data['organization_version'], 409, 'País mestre alterado. Recarregue.');
abort_unless($snapshot['country'] !== null, 422, 'País de constituição não informado no cadastro mestre.');
$payload = json_decode(Crypt::decryptString($row->payload), true, 512, JSON_THROW_ON_ERROR);
$payload['incorporation_country_code'] = $snapshot['country'];
$payload['organization_country_snapshot'] = $snapshot;
@@ -116,11 +148,12 @@ class InvestmentWorkspaceController extends Controller
DB::table('investment_company_declarations')->where('id', $id)->update(['payload' => $encrypted, 'version' => $row->version + 1, 'updated_at' => now()]);
$this->audit($request, 'organization_country_reused', 'investment_company_declarations', $id, ['version' => $row->version + 1,
'before_payload' => $row->payload, 'after_payload' => $encrypted, 'organization_id' => $data['organization_id'], 'organization_version' => $snapshot['organization_version']]);
$commands->record($request->user(), $data['operation_key'] ?? null, $command, ['version' => $row->version + 1, 'country' => $snapshot['country']]);
return ['version' => $row->version + 1, 'country' => $snapshot['country']];
});
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'País mestre reutilizado nesta declaração; manifestações anteriores não alteradas.');
return $request->expectsJson() ? response()->json($result)->header('Cache-Control', 'no-store, private') : back()->with('success', 'País mestre reutilizado nesta declaração; manifestações anteriores não alteradas.');
}
public function notifications(Request $request): Response|JsonResponse
@@ -16,6 +16,7 @@ use App\Services\InvestmentZoneOperationalProfile;
use App\Services\InvestmentZonePathContext;
use App\Services\InvestmentZoneProfileInstallation;
use App\Services\InvestmentZones;
use App\Services\PublicSearchMetrics;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -108,6 +109,9 @@ class InvestmentZoneController extends Controller
$publicColumns = array_values(array_filter($publicColumns, fn ($column) => Schema::hasColumn('investment_zones', $column)));
$rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id')
->paginate(12, $management ? ['*'] : $publicColumns)->withQueryString();
if (! $management) {
app(PublicSearchMetrics::class)->candidate($request, 'zones', $rows->total(), $filters);
}
$rows->getCollection()->transform(function (InvestmentZone $zone) use ($management) {
$territoryPath = app(InvestmentGeography::class)->path($zone->primary_territory_id, $management);
$zone->setAttribute('province', collect($territoryPath)->firstWhere('location_type', 'province')->name ?? $zone->province);
@@ -2,7 +2,9 @@
namespace App\Http\Controllers;
use App\Services\CompanyComparison;
use App\Services\FintechInvestment;
use App\Services\InvestmentGuides;
use App\Services\InvestmentZonePathContext;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
@@ -19,7 +21,9 @@ class InvestorPathController extends Controller
public function index(Request $request, InvestorPath $path): JsonResponse|Response
{
abort_unless($path->available(), 404);
$data = ['contexts' => $path->contexts($request->user())];
$data = ['contexts' => $path->contexts($request->user()),
'companyComparisonAnswers' => $request->has('company_comparison') ? app(CompanyComparison::class)->answers(['answers' => $request->input('company_comparison')]) : null,
'guideSelection' => $request->has('investment_guide') ? app(InvestmentGuides::class)->selection($request->array('investment_guide')) : null];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investor-path/index', $data);
}
@@ -57,6 +61,9 @@ class InvestorPathController extends Controller
public function create(Request $request, InvestorPath $path): JsonResponse|RedirectResponse
{
$comparison = $request->has('company_comparison') ? app(CompanyComparison::class)->answers(['answers' => $request->input('company_comparison')]) : null;
$guide = $request->has('investment_guide') ? app(InvestmentGuides::class)->selection($request->array('investment_guide')) : null;
abort_if($comparison !== null && $guide !== null, 422, 'Escolha o contexto de preparação que pretende continuar.');
$zone = $request->has('zone_id') ? app(InvestmentZonePathContext::class)->input($request->all()) : null;
if ($zone) {
$request->validate(['confirm' => 'required|accepted']);
@@ -92,7 +99,12 @@ class InvestorPathController extends Controller
return $id;
});
return $request->expectsJson() ? response()->json(['id' => $id], 201) : redirect('/investimentos/'.$id)->with('success', 'Investimento guardado no seu dossier.');
$href = $comparison === null ? '/investimentos/'.$id : '/investimentos/'.$id.'/forma-juridica?'.http_build_query(['answers' => $comparison]);
if ($guide !== null) {
$href = '/investimentos/'.$id.'/guias/'.$guide['slug'].'?'.http_build_query(['current' => 1, 'selection' => 1, 'sha256' => $guide['sha256'], 'instruments' => $guide['instruments']]);
}
return $request->expectsJson() ? response()->json(['id' => $id, ...($comparison !== null ? ['comparison_href' => $href] : []), ...($guide !== null ? ['guide_href' => $href] : [])], 201) : redirect($href)->with('success', 'Investimento guardado no seu dossier.');
}
public function show(Request $request, string $id, InvestorPath $path): JsonResponse|Response
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Services\InvestorPath;
use App\Services\LegacyContent;
use App\Services\PublicSearchMetrics;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -24,6 +25,7 @@ class LegacyContentController extends Controller
$articles = array_values(array_filter($articles, fn ($item) => $q === ''
|| str_contains(mb_strtolower(strip_tags($item['html'].' '.$item['title'])), mb_strtolower($q))));
$match = $articles !== [];
app(PublicSearchMetrics::class)->candidate($request, 'sector_content', count($articles), ['q' => $q]);
return Inertia::render('portal/legacy', ['article' => $article, 'articles' => $articles, 'view' => 'landing', 'query' => $q, 'match' => $match]);
}
@@ -14,6 +14,7 @@ use App\Services\OpportunityMaturity;
use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityPromotion;
use App\Services\OpportunityResources;
use App\Services\OpportunityTechnicalIncidents;
use App\Services\OpportunityWorkflowTasks;
use App\Services\PortalProfileAccess;
use App\Services\ProactiveInvestment;
@@ -139,6 +140,7 @@ class OpportunityManagementController extends Controller
'canEditDraft' => $record ? $access->canEdit($request->user(), $record) : false,
'workflowParticipants' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->participants($request->user(), $record) : ['validators' => [], 'publishers' => []],
'workflowTasks' => $record && $record->owner_user_id !== null ? app(OpportunityWorkflowTasks::class)->listing($record) : [],
'workflowIncidents' => $record && $record->owner_user_id !== null ? app(OpportunityTechnicalIncidents::class)->recent($request->user(), $record) : [],
'profileMode' => $record ? $record->owner_user_id !== null : str_starts_with($request->path(), 'minhas-oportunidades'),
'profileActions' => $record && $record->owner_user_id !== null ? app(OpportunityProfileWorkflow::class)->actions($request->user(), $record) : [], 'profileContext' => $record?->workflow_context, 'profileOrganization' => $record?->organization_id]);
$values = $record ? $service->values($record) : [];
@@ -10,6 +10,8 @@ use App\Services\OpportunityLibrarySupport;
use App\Services\OpportunityManagement;
use App\Services\OpportunityResourceInstallation;
use App\Services\OpportunityResources;
use App\Services\OpportunityTechnicalExecution;
use App\Services\OpportunityTechnicalIncidents;
use App\Services\OpportunityTerritorialPresentation;
use App\Services\OrganizationCountryInstallation;
use App\Services\PortalProfileAccess;
@@ -75,7 +77,7 @@ class OpportunityResourceController extends Controller
return hash('sha256', json_encode([$request->user()->getAuthIdentifier(), $plan['target'], $plan['migration'], $plan['code_hash']], JSON_THROW_ON_ERROR));
}
public function index(int $id, OpportunityResources $service): Response
public function index(int $id, OpportunityResources $service): \Symfony\Component\HttpFoundation\Response
{
$opportunity = Opportunity::withTrashed()->findOrFail($id);
app(PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity);
@@ -100,7 +102,9 @@ class OpportunityResourceController extends Controller
}
}
return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'librarySources' => ! app(PortalProfileAccess::class)->canEdit(request()->user(), $opportunity) ? [] : app(OpportunityLibrarySupport::class)->choices(request()->user()), 'existingLocations' => DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')->where('opportunity_id', $id)->get(['g.id', 'g.name', 'g.location_type']), 'locationReference' => app(OpportunityTerritorialPresentation::class)->describe($opportunity, $resources['locations']), 'assessment' => $this->assessment($resources)]);
Inertia::encryptHistory();
return tap(Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'technicalIncidents' => app(OpportunityTechnicalIncidents::class)->recent(request()->user(), $opportunity), 'librarySources' => ! app(PortalProfileAccess::class)->canEdit(request()->user(), $opportunity) ? [] : app(OpportunityLibrarySupport::class)->choices(request()->user()), 'existingLocations' => DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')->where('opportunity_id', $id)->get(['g.id', 'g.name', 'g.location_type']), 'locationReference' => app(OpportunityTerritorialPresentation::class)->describe($opportunity, $resources['locations']), 'assessment' => $this->assessment($resources)])->toResponse(request()), static fn (\Symfony\Component\HttpFoundation\Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function reuseLibrary(Request $request, int $id): JsonResponse
@@ -116,9 +120,18 @@ class OpportunityResourceController extends Controller
if (is_string($input['payload'] ?? null)) {
$input['payload'] = json_decode($input['payload'], true);
}
$record = $service->save($request->user(), $id, $kind, $input, $request->file('file'), $resourceId);
$operation = function () use ($request, $id, $kind, $input, $resourceId, $service) {
$record = $service->save($request->user(), $id, $kind, $input, $request->file('file'), $resourceId);
return response()->json(['id' => $record->id, 'message' => 'Recurso guardado no servidor. Não publicado; validação final e scan são independentes.', 'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id))], $resourceId ? 200 : 201);
return response()->json(['id' => $record->id, 'message' => 'Recurso guardado no servidor. Não publicado; validação final e scan são independentes.', 'etag' => app(OpportunityManagement::class)->etag(Opportunity::findOrFail($id))], $resourceId ? 200 : 201);
};
if (! $request->hasFile('file') || ! in_array($kind, ['documents', 'images'], true)) {
return $operation();
}
$before = Opportunity::withTrashed()->findOrFail($id);
app(PortalProfileAccess::class)->authorize($request->user(), 'edit', $before);
return app(OpportunityTechnicalExecution::class)->run($request->user(), $before, $kind === 'images' ? 'upload_image' : 'upload_document', $input, $operation);
}
public function lifecycle(Request $request, int $id, string $kind, string $resourceId, OpportunityResources $service): JsonResponse
@@ -130,9 +143,14 @@ class OpportunityResourceController extends Controller
public function prepareImage(Request $request, int $id, string $resourceId, OpportunityImageProcessor $service): JsonResponse
{
$service->prepare($request->user(), $id, $resourceId, $request->all());
$before = Opportunity::withTrashed()->findOrFail($id);
app(PortalProfileAccess::class)->authorize($request->user(), 'edit', $before);
return response()->json(['message' => 'Derivadas WebP geradas sem EXIF original; scan independente e revisão de divulgação pendentes.']);
return app(OpportunityTechnicalExecution::class)->run($request->user(), $before, 'prepare_image', $request->all(), function () use ($request, $id, $resourceId, $service) {
$service->prepare($request->user(), $id, $resourceId, $request->all());
return response()->json(['message' => 'Derivadas WebP geradas sem EXIF original; scan independente e revisão de divulgação pendentes.']);
});
}
public function previewImage(int $id, string $resourceId, string $documentId): \Illuminate\Http\Response|BinaryFileResponse
@@ -2,26 +2,35 @@
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\ExistingBusiness;
use App\Models\Sector;
use App\Models\User;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentRelations;
use App\Services\PartnershipAgreementHistory;
use App\Services\PartnershipAgreementProjection;
use App\Services\PartnershipAgreements;
use App\Services\PartnershipCandidacies;
use App\Services\PartnershipCandidacyHistory;
use App\Services\PartnershipDraftSaves;
use App\Services\PartnershipExchange;
use App\Services\PartnershipMatching;
use App\Services\PartnershipMilestones;
use App\Services\PartnershipNotifications;
use App\Services\PartnershipPublication;
use App\Services\PartnershipPublicationHistory;
use App\Services\PartnershipRoomUploads;
use App\Services\PartnershipSources;
use App\Services\PublicSearchMetrics;
use App\Services\SectorOrganization;
use App\Services\VerifiedDocumentBinary;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -82,6 +91,7 @@ class PartnershipExchangeController extends Controller
}
$results = $query->orderByDesc('p.id')->select(['p.id', 'p.title', 'p.sector', 'p.province', 'p.kind', 'p.partnership_type', 'p.sector_id', 'p.deadline', 'p.published_at'])
->selectRaw("p.payload->>'summary' as summary")->paginate(12)->withQueryString();
app(PublicSearchMetrics::class)->candidate($request, 'partnerships', $results->total(), $filters);
if ($request->expectsJson()) {
return response()->json($results);
}
@@ -119,16 +129,23 @@ class PartnershipExchangeController extends Controller
public function dashboard(Request $request): Response
{
$actor = $request->user();
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
$exchange = app(PartnershipExchange::class);
$organizations = $exchange->readableOrganizations($actor);
$mine = DB::table('portal_partnerships')->where(fn ($q) => $q->where(fn ($own) => $own->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->latest()->limit(100)->get();
$interests = DB::table('partnership_interests as i')->join('portal_partnerships as p', 'p.id', '=', 'i.partnership_id')
->leftJoin('partnership_rooms as room', 'room.interest_id', '=', 'i.id')
->where(fn ($q) => $q->where(fn ($own) => $own->where('i.user_id', $actor->id)->whereNull('i.organization_id'))->orWhereIn('i.organization_id', $organizations))
->select(['i.id', 'i.partnership_id', 'i.status', 'i.decision_reason', 'p.title', 'room.id as room_id'])->orderByDesc('i.id')->limit(100)->get();
->select(['i.id', 'i.partnership_id', 'i.status', 'i.decision_reason', 'p.title', 'p.status as partnership_status', 'p.user_id', 'p.organization_id', 'room.id as room_id'])->orderByDesc('i.id')->limit(100)->get()
->map(function ($interest) use ($actor, $exchange) {
$canRead = $interest->partnership_status === 'published' || $exchange->owns($actor, $interest, false) || Gate::forUser($actor)->allows('manage-portal');
return ['id' => $interest->id, 'partnership_id' => $interest->partnership_id, 'status' => $interest->status, 'decision_reason' => $interest->decision_reason,
'title' => $canRead ? $interest->title : null, 'partnership_href' => $canRead ? '/parcerias/'.$interest->partnership_id : null, 'room_id' => $interest->room_id];
});
$moderation = Gate::forUser($actor)->allows('manage-portal') ? DB::table('portal_partnerships')->whereIn('status', ['submitted', 'pending'])->orderBy('updated_at')->get(['id', 'title', 'status', 'version']) : [];
return Inertia::render('partnerships/dashboard', ['mine' => $mine->map(fn ($p) => ['id' => $p->id, 'title' => $p->title, 'status' => $p->status, 'version' => $p->version]), 'interests' => $interests, 'moderation' => $moderation,
'notifications' => DB::table('partnership_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(50)->get()]);
'notifications' => app(PartnershipNotifications::class)->forUser($actor)]);
}
public function edit(Request $request, ?int $id = null): Response
@@ -141,7 +158,10 @@ class PartnershipExchangeController extends Controller
$origins = [['type' => $source['type'], 'target_id' => $source['target_id']]];
}
return Inertia::render('partnerships/editor', ['record' => $record ? [...(array) $record, 'payload' => json_decode($record->payload, true)] : null, 'origins' => $origins,
$payload = $record ? json_decode($record->payload, true) : [];
unset($payload['_creation_hash']);
return Inertia::render('partnerships/editor', ['editorActorId' => $request->user()->id, 'saveCommands' => $record ? app(PartnershipDraftSaves::class)->commands($request->user(), $record) : [], 'record' => $record ? [...(array) $record, 'payload' => $payload] : null, 'origins' => $origins,
'locations' => $record ? DB::table('partnership_locations')->where('partnership_id', $id)->get() : [], ...$this->catalogues($request)]);
}
@@ -149,7 +169,7 @@ class PartnershipExchangeController extends Controller
{
$record = app(PartnershipExchange::class)->save($request->user(), $request->all(), $id);
return $request->expectsJson() ? response()->json(['id' => $record->id, 'version' => $record->version, 'status' => $record->status]) : redirect('/parcerias/'.$record->id.'/editar')->with('success', 'Rascunho guardado.');
return $request->expectsJson() ? response()->json(['id' => $record->id, 'version' => $record->version, 'status' => $record->status, 'replayed' => (bool) ($record->command_replayed ?? false), 'saved_version' => $record->command_saved_version ?? null]) : redirect('/parcerias/'.$record->id.'/editar')->with('success', 'Rascunho guardado.');
}
public function show(Request $request, int $id): Response|JsonResponse
@@ -163,17 +183,23 @@ class PartnershipExchangeController extends Controller
$privateDetails = ($payload['confidentiality'] ?? 'public') === 'private' && ! $owner && ! $moderator;
$mine = $request->user() ? DB::table('partnership_interests')->where('partnership_id', $id)->get()->filter(fn ($i) => app(PartnershipExchange::class)->owns($request->user(), $i, false))->values() : collect();
$interests = $owner ? DB::table('partnership_interests')->where('partnership_id', $id)->orderBy('id')->get() : $mine;
$interestData = $interests->map(function ($interest) use ($record) {
$commands = $request->user() ? app(PartnershipCandidacies::class)->commands($request->user(), $id) : [];
$interestData = $interests->map(function ($interest) use ($record, $request, $commands) {
return [...(array) $interest, 'payload' => json_decode($interest->payload, true), 'assessment' => json_decode($interest->assessment, true),
'can_edit' => $request->user() && app(PartnershipExchange::class)->owns($request->user(), $interest), 'commands' => $commands[(int) $interest->id] ?? [],
'match' => app(PartnershipMatching::class)->explain($record, $interest), 'room_id' => DB::table('partnership_rooms')->where('interest_id', $interest->id)->value('id')];
});
$props = ['record' => ['id' => $record->id, 'title' => $record->title, 'status' => $record->status, 'version' => $record->version, 'kind' => $record->kind,
'partnership_type' => $record->partnership_type, 'sector' => $record->sector, 'province' => $record->province, 'sector_id' => $record->sector_id, 'deadline' => $record->deadline,
'offer' => $privateDetails ? null : $record->offer, 'seeking' => $privateDetails ? null : $record->seeking, 'payload' => $privateDetails ? ['summary' => $payload['summary'] ?? '', 'confidentiality' => 'private'] : $payload],
'owner' => (bool) $owner, 'canEdit' => $request->user() && app(PartnershipExchange::class)->owns($request->user(), $record), 'moderator' => (bool) $moderator,
'acceptingInterests' => app(PartnershipCandidacies::class)->accepting($record),
'canStartInterest' => $request->user() && ! $owner && ! DB::table('partnership_interests')->where('partnership_id', $id)->where('user_id', $request->user()->id)->exists(),
'origins' => app(PartnershipSources::class)->forPartnership($id, $owner ? $request->user() : null), 'interests' => $interestData,
'locations' => app(InvestmentRelations::class)->locations(array_values(DB::table('partnership_locations')->where('partnership_id', $id)->get()->map(fn ($l) => ['id' => $l->geo_location_id, 'role' => $l->role, 'source_reference' => $l->source_reference])->all())),
'history' => ($owner || $moderator) ? DB::table('partnership_events')->where('partnership_id', $id)->where('event_type', 'like', 'partnership_opportunity.%')->orderBy('occurred_at')->get(['event_type', 'actor_id', 'occurred_at', 'payload']) : [],
'historySummary' => ($owner || $moderator) ? app(PartnershipPublicationHistory::class)->summary($request->user(), $id) : null,
'publicationActions' => app(PartnershipPublication::class)->actions($request->user(), $record),
'publicationCommands' => app(PartnershipPublication::class)->commands($request->user(), $record),
...$this->catalogues($request)];
if ($request->user()) {
Inertia::encryptHistory();
@@ -182,39 +208,74 @@ class PartnershipExchangeController extends Controller
return $request->expectsJson() ? response()->json($props)->header('Cache-Control', 'private, no-store') : Inertia::render('partnerships/detail', $props);
}
public function publicationHistory(Request $request, int $id): Response|JsonResponse
{
$data = $request->validate(['before' => 'nullable|string|max:4000']);
$history = app(PartnershipPublicationHistory::class)->page($request->user(), $id, $data['before'] ?? null);
return $request->expectsJson() ? response()->json($history) : Inertia::render('partnerships/publication-history', $history);
}
public function transition(Request $request, int $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000']);
$record = app(PartnershipExchange::class)->transition($request->user(), $id, $data['version'], $data['action'], $data['reason']);
$data = $request->validate(['client_key' => 'nullable|uuid', 'version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000']);
$record = app(PartnershipExchange::class)->transition($request->user(), $id, $data['version'], $data['action'], $data['reason'], $data['client_key'] ?? null);
return $request->expectsJson() ? response()->json(['status' => $record->status, 'version' => $record->version]) : back()->with('success', 'Decisão registada.');
return $request->expectsJson() ? response()->json(['id' => $record->id, 'status' => $record->status, 'version' => $record->version, 'replayed' => (bool) ($record->command_replayed ?? false)]) : back()->with('success', 'Decisão registada.');
}
public function interest(Request $request, int $id): RedirectResponse|JsonResponse
{
$interest = app(PartnershipExchange::class)->interest($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['id' => $interest->id, 'version' => $interest->version]) : back()->with('success', 'Interesse guardado.');
return $request->expectsJson() ? response()->json(['id' => $interest->id, 'version' => $interest->version, 'replayed' => (bool) ($interest->command_replayed ?? false)]) : back()->with('success', 'Interesse guardado.');
}
public function decide(Request $request, int $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000', 'assessment' => 'array|max:7']);
$interest = app(PartnershipExchange::class)->decideInterest($request->user(), $id, $data['version'], $data['action'], $data['reason'], $data['assessment'] ?? []);
$data = $request->validate(['client_key' => 'nullable|uuid', 'partnership_version' => 'nullable|integer|min:1', 'weights_hash' => 'nullable|string|regex:/^[a-f0-9]{64}$/', 'version' => 'required|integer|min:1', 'action' => 'required|string', 'reason' => 'required|string|max:2000', 'assessment' => 'array|max:7']);
$interest = app(PartnershipExchange::class)->decideInterest($request->user(), $id, $data['version'], $data['action'], $data['reason'], $data['assessment'] ?? [], $data['partnership_version'] ?? null, $data['weights_hash'] ?? null, $data['client_key'] ?? null);
return $request->expectsJson() ? response()->json(['status' => $interest->status, 'version' => $interest->version]) : back()->with('success', 'Candidatura atualizada.');
return $request->expectsJson() ? response()->json(['status' => $interest->status, 'version' => $interest->version, 'replayed' => (bool) ($interest->command_replayed ?? false)]) : back()->with('success', 'Candidatura atualizada.');
}
public function candidacyHistory(Request $request, int $id): Response|JsonResponse
{
$data = $request->validate(['before' => 'nullable|string|max:4000']);
$history = app(PartnershipCandidacyHistory::class)->page($request->user(), $id, $data['before'] ?? null);
return $request->expectsJson() ? response()->json($history) : Inertia::render('partnerships/interest-history', $history);
}
public function agreementHistory(Request $request, int $id): Response|JsonResponse
{
$data = $request->validate(['before' => 'nullable|string|max:4000']);
$history = app(PartnershipAgreementHistory::class)->page($request->user(), $id, $data['before'] ?? null);
return $request->expectsJson() ? response()->json($history) : Inertia::render('partnerships/agreement-history', $history);
}
public function room(Request $request, int $id): Response|JsonResponse
{
$context = app(PartnershipExchange::class)->room($request->user(), $id);
$props = [...$context, 'room' => [...(array) $context['room'], 'agreement' => $context['room']->agreement ? json_decode($context['room']->agreement, true) : null],
'messages' => DB::table('partnership_messages as m')->join('users as u', 'u.id', '=', 'm.author_id')->where('room_id', $id)->orderBy('m.id')->get(['m.id', 'm.body', 'm.created_at', 'm.author_id', 'u.name']),
$participants = app(PartnershipExchange::class)->participants($context['partnership'], $context['interest']);
$participantIds = array_column($participants, 'id');
$canWrite = app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) || app(PartnershipExchange::class)->owns($request->user(), $context['interest']);
$canAssign = $canWrite && in_array($context['room']->status, ['agreement_recorded', 'agreed', 'execution'], true);
$creationKeys = app(PartnershipMilestones::class)->creationKeys($request->user(), (int) $context['partnership']->id, $id);
$partnership = (array) $context['partnership'];
$payload = json_decode($partnership['payload'], true, 512, JSON_THROW_ON_ERROR);
unset($payload['_creation_hash']);
$partnership['payload'] = json_encode($payload, JSON_THROW_ON_ERROR);
$props = [...$context, 'partnership' => $partnership, 'room' => [...(array) $context['room'], 'agreement' => app(PartnershipAgreementProjection::class)->current($context['room']->agreement)],
'messages' => DB::table('partnership_messages as m')->join('users as u', 'u.id', '=', 'm.author_id')->where('room_id', $id)->orderBy('m.id')->get(['m.id', 'm.body', 'm.created_at', 'm.author_id', 'm.client_key', 'u.name'])
->map(fn ($message) => [...(array) $message, 'client_key' => (int) $message->author_id === $request->user()->id ? $message->client_key : null]),
'documents' => DB::table('partnership_documents as link')->join('documents as d', 'd.id', '=', 'link.document_id')->where('room_id', $id)
->where(fn ($q) => $q->where('link.shared', true)->orWhere('link.owner_id', $request->user()->id))
->get(['link.id', 'link.shared', 'link.owner_id', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.id as document_id']),
'milestones' => DB::table('partnership_milestones')->where('room_id', $id)->orderBy('id')->get(),
'canWrite' => app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) || app(PartnershipExchange::class)->owns($request->user(), $context['interest'])];
'milestones' => DB::table('partnership_milestones as m')->leftJoin('users as responsible', 'responsible.id', '=', 'm.responsible_id')->where('m.room_id', $id)->orderBy('m.id')
->get(['m.*', 'responsible.name as responsible_name'])->map(fn ($milestone) => [...(array) $milestone, 'responsible_is_participant' => in_array((int) $milestone->responsible_id, $participantIds, true), 'client_key' => $creationKeys[(int) $milestone->id] ?? null]),
'agreementActorId' => $request->user()->id, 'agreementParty' => app(PartnershipAgreements::class)->party($request->user(), $context), 'agreementCommands' => app(PartnershipAgreements::class)->commands($request->user(), $context), 'participants' => $canAssign ? $participants : [], 'canWrite' => $canWrite];
return $request->expectsJson() ? response()->json($props) : Inertia::render('partnerships/room', $props);
}
@@ -223,9 +284,10 @@ class PartnershipExchangeController extends Controller
{
$data = $request->validate(['client_key' => 'required|uuid', 'body' => 'required|string|max:15000']);
DB::transaction(function () use ($request, $id, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$context = app(PartnershipExchange::class)->room($request->user(), $id);
abort_unless(app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) || app(PartnershipExchange::class)->owns($request->user(), $context['interest']), 403);
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless($locked !== null && ! in_array($locked->status, ['closed', 'cancelled'], true), 409, 'Sala encerrada.');
abort_unless($locked !== null, 404);
$key = ['room_id' => $id, 'author_id' => $request->user()->id, 'client_key' => $data['client_key']];
$existing = DB::table('partnership_messages')->where($key)->first();
if ($existing) {
@@ -233,6 +295,7 @@ class PartnershipExchangeController extends Controller
return;
}
abort_if(in_array($locked->status, ['closed', 'cancelled'], true), 409, 'Sala encerrada.');
DB::table('partnership_messages')->insert([...$key, 'body' => $data['body']]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.message_created.v1', ['room_id' => $id], 'Nova mensagem privada', [$context['partnership']->user_id, $context['interest']->user_id]);
});
@@ -242,100 +305,31 @@ class PartnershipExchangeController extends Controller
public function agreement(Request $request, int $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'action' => 'required|in:record,confirm,start,complete,cancel,renew', 'title' => 'required_if:action,record|nullable|string|max:200',
'terms' => 'required_if:action,record|nullable|string|max:20000', 'reason' => 'required|string|max:2000']);
DB::transaction(function () use ($request, $id, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$room = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless((int) $room->version === (int) $data['version'], 409, 'Acordo alterado.');
$agreement = $room->agreement ? json_decode($room->agreement, true) : null;
$status = $room->status;
if ($data['action'] === 'record') {
abort_unless(in_array($status, ['negotiation', 'agreement_recorded'], true), 409);
$previous = $agreement['previous_versions'] ?? [];
if ($agreement) {
$previous[] = ['title' => $agreement['title'], 'terms' => $agreement['terms'], 'confirmations' => $agreement['confirmations'], 'recorded_at' => $agreement['recorded_at']];
}
$agreement = ['title' => $data['title'], 'terms' => $data['terms'], 'recorded_by' => $request->user()->id, 'recorded_at' => now()->toIso8601String(), 'confirmations' => [],
'parties' => [$context['partnership']->user_id, $context['interest']->user_id], 'legal_signature_status' => 'not_verified', 'previous_versions' => $previous];
$status = 'agreement_recorded';
} elseif ($data['action'] === 'confirm') {
abort_unless($status === 'agreement_recorded' && $agreement, 409);
$party = app(PartnershipExchange::class)->owns($request->user(), $context['partnership']) ? 'proponent' : 'candidate';
$agreement['confirmations'][$party] = ['actor_id' => $request->user()->id, 'date' => now()->toIso8601String(), 'reason' => $data['reason']];
if (count($agreement['confirmations']) === 2) {
$status = 'agreed';
}
} else {
$allowed = ['start' => ['agreed'], 'complete' => ['execution'], 'cancel' => ['negotiation', 'agreement_recorded', 'agreed', 'execution'], 'renew' => ['agreed', 'execution']];
abort_unless(in_array($status, $allowed[$data['action']] ?? [], true), 409);
if ($data['action'] === 'complete') {
abort_if(DB::table('partnership_milestones')->where('room_id', $id)->where('status', '!=', 'completed')->exists(), 409, 'Existem marcos pendentes.');
}
$status = ['start' => 'execution', 'complete' => 'closed', 'cancel' => 'cancelled', 'renew' => 'negotiation'][$data['action']];
if ($data['action'] === 'renew') {
$agreement['previous_versions'][] = ['title' => $agreement['title'], 'terms' => $agreement['terms'], 'confirmations' => $agreement['confirmations']];
$agreement['confirmations'] = [];
}
}
DB::table('partnership_rooms')->where('id', $id)->update(['agreement' => $agreement ? json_encode($agreement) : null, 'status' => $status, 'version' => $room->version + 1, 'updated_at' => now()]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership_agreement.changed.v1', ['room_id' => $id, 'status' => $status, 'version' => $room->version + 1, 'reason' => $data['reason']], 'Acordo: '.$status, [$context['partnership']->user_id, $context['interest']->user_id]);
});
$result = app(PartnershipAgreements::class)->act($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json(['recorded' => true]) : back()->with('success', 'Acordo atualizado; assinatura jurídica não verificada.');
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'Acordo atualizado; assinatura jurídica não verificada.');
}
public function milestone(Request $request, int $id, ?int $milestone = null): RedirectResponse|JsonResponse
{
$data = $request->validate(['title' => 'required|string|max:200', 'responsible_id' => 'required|integer', 'due_date' => 'nullable|date_format:Y-m-d', 'status' => 'required|in:pending,progress,completed',
$data = $request->validate(['client_key' => 'nullable|uuid', 'title' => 'required|string|max:200', 'responsible_id' => 'required|integer', 'due_date' => 'nullable|date_format:Y-m-d', 'status' => 'required|in:pending,progress,completed',
'evidence' => 'required_if:status,completed|nullable|string|max:10000', 'version' => ($milestone ? 'required' : 'nullable').'|integer|min:1']);
DB::transaction(function () use ($request, $id, $milestone, $data) {
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless($locked !== null && in_array($locked->status, ['agreement_recorded', 'agreed', 'execution'], true), 409);
$responsible = User::query()->whereKey($data['responsible_id'])->first();
abort_unless($responsible !== null && (app(PartnershipExchange::class)->owns($responsible, $context['partnership']) || app(PartnershipExchange::class)->owns($responsible, $context['interest'])), 422, 'Responsável deve ser participante identificado.');
if ($milestone) {
$existing = DB::table('partnership_milestones')->where('room_id', $id)->where('id', $milestone)->lockForUpdate()->first();
abort_unless($existing !== null, 404);
abort_unless((int) $existing->version === (int) $data['version'], 409);
DB::table('partnership_milestones')->where('id', $milestone)->update([...$data, 'version' => $existing->version + 1, 'updated_at' => now()]);
} else {
unset($data['version']);
$milestone = DB::table('partnership_milestones')->insertGetId([...$data, 'room_id' => $id, 'created_at' => now(), 'updated_at' => now()]);
}
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership_milestone.changed.v1', ['milestone_id' => $milestone, 'status' => $data['status']], 'Marco atualizado', [$context['partnership']->user_id, $context['interest']->user_id]);
});
if (isset($data['client_key'])) {
$data['client_key'] = strtolower($data['client_key']);
}
$result = app(PartnershipMilestones::class)->save($request->user(), $id, $data, $milestone);
return $request->expectsJson() ? response()->json(['saved' => true]) : back()->with('success', 'Marco guardado.');
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'Marco guardado.');
}
public function upload(Request $request, int $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240', 'shared' => 'required|boolean']);
$context = app(PartnershipExchange::class)->room($request->user(), $id, true);
$data = $request->validate(['client_key' => 'nullable|uuid', 'file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240', 'shared' => 'required|boolean']);
$file = $request->file('file');
$documentId = (string) Str::uuid();
$stored = $file->storeAs('partnerships/'.$id, $documentId, 'local');
abort_unless(is_string($stored) && $stored !== '', 503);
try {
DB::transaction(function () use ($request, $id, $data, $file, $documentId, $stored, $context) {
app(PartnershipExchange::class)->room($request->user(), $id, true);
$locked = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless($locked !== null && ! in_array($locked->status, ['closed', 'cancelled'], true), 409);
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'partnership-exchange', 'data_classification' => 'restricted',
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $stored,
'sha256' => hash_file('sha256', Storage::disk('local')->path($stored)), 'status' => 'quarantined', 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id]);
DB::table('partnership_documents')->insert(['room_id' => $id, 'document_id' => $documentId, 'owner_id' => $request->user()->id, 'shared' => $data['shared']]);
app(PartnershipExchange::class)->event($context['partnership'], $request->user(), 'partnership.document_added.v1', ['room_id' => $id, 'document_id' => $documentId, 'shared' => $data['shared']], 'Documento privado anexado');
});
} catch (\Throwable $e) {
Storage::disk('local')->delete($stored);
throw $e;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
abort_unless($file instanceof UploadedFile, 422);
$result = app(PartnershipRoomUploads::class)->save($request->user(), $id, $file, (bool) $data['shared'], isset($data['client_key']) ? strtolower($data['client_key']) : null);
return $request->expectsJson() ? response()->json(['uploaded' => true]) : back()->with('success', 'Documento privado guardado; análise antimalware pendente.');
return $request->expectsJson() ? response()->json($result) : back()->with('success', 'Envio reconciliado com o documento privado. Consulte o estado da análise na lista.')->with('uploaded_document_id', $result['document_id']);
}
public function download(Request $request, int $id, int $attachment): \Illuminate\Http\Response|BinaryFileResponse
@@ -367,7 +361,7 @@ class PartnershipExchangeController extends Controller
public function notification(Request $request, int $id): RedirectResponse
{
abort_unless(DB::table('partnership_notifications')->where('id', $id)->where('user_id', $request->user()->id)->update(['read_at' => now()]) > 0, 404);
app(PartnershipNotifications::class)->markRead($request->user(), $id);
return back();
}
@@ -2,12 +2,14 @@
namespace App\Http\Controllers;
use App\Exceptions\OpportunityWorkflowUnavailable;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityCanonicalContract;
use App\Services\OpportunityContacts;
use App\Services\OpportunityManagement;
use App\Services\OpportunityProfileWorkflow;
use App\Services\OpportunityTechnicalSupport;
use App\Services\OpportunityWorkflowExecution;
use App\Services\PortalInternalDesignation;
use App\Services\PortalProfileAccess;
use App\Services\PortalProfileAdministration;
@@ -49,7 +51,7 @@ class PortalProfileController extends Controller
{
$service->administrative($request->user());
return Inertia::render('profiles/administration', ['requests' => DB::table('portal_profile_requests')->latest()->get(),
return Inertia::render('profiles/administration', ['technicalSupportAvailable' => app(OpportunityTechnicalSupport::class)->installed(), 'requests' => DB::table('portal_profile_requests')->latest()->get(),
'assignments' => DB::table('portal_role_assignments')->get(), 'pair' => DB::table('portal_superuser_pair')->first(),
'operatorOrganizations' => app(PortalInternalDesignation::class)->organizations(),
'staffAccounts' => User::query()->select(['id', 'name', 'email', 'email_verified_at'])
@@ -150,9 +152,14 @@ class PortalProfileController extends Controller
return app(OpportunityManagementController::class)->edit($request, app(OpportunityManagement::class), $id);
}
public function workflow(Request $request, int $id, OpportunityProfileWorkflow $service): JsonResponse
public function workflow(Request $request, int $id, OpportunityWorkflowExecution $service): JsonResponse
{
$record = $service->transition($request->user(), $id, $request->all());
try {
$record = $service->transition($request->user(), $id, $request->all());
} catch (OpportunityWorkflowUnavailable $failure) {
return response()->json(['message' => 'Não foi possível confirmar o resultado. Consulte o estado da oportunidade antes de repetir. '.($failure->recorded ? 'Incidente técnico registado. ' : 'Não foi possível guardar o incidente; conserve esta referência. ').'Referência: '.$failure->reference,
'reference' => $failure->reference, 'incident_recorded' => $failure->recorded], 503)->header('Cache-Control', 'private, no-store');
}
return response()->json(['id' => $id, 'status' => app(PortalProfileAccess::class)->state($record), 'publication_status' => $record->status, 'etag' => app(OpportunityManagement::class)->etag($record), 'message' => 'Ação interna registada; não representa decisão externa.']);
}
@@ -7,6 +7,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
class PortalProfileInstallationController extends Controller
{
@@ -22,6 +23,19 @@ class PortalProfileInstallationController extends Controller
return response()->json($installation->apply($request->user(), $request->all()));
}
public function supportPreview(Request $request, PortalProfileInstallation $installation): HttpResponse
{
$plan = $installation->supportPreview($request->user());
Inertia::encryptHistory();
return tap($request->expectsJson() ? response()->json($plan) : Inertia::render('profiles/support-installation', $plan)->toResponse($request), static fn (HttpResponse $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function applySupport(Request $request, PortalProfileInstallation $installation): JsonResponse
{
return response()->json($installation->applySupport($request->user(), $request->all()))->header('Cache-Control', 'private, no-store');
}
public function bootstrap(Request $request, PortalProfileInstallation $installation): JsonResponse
{
return response()->json($installation->bootstrap($request->user(), $request->all()));
@@ -17,6 +17,7 @@ use App\Services\LegacyContent;
use App\Services\Malware\MalwareScanner;
use App\Services\PortalProfileAccess;
use App\Services\PublicDocumentPresentation;
use App\Services\PublicSearchMetrics;
use App\Services\SectorOrganization;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
@@ -127,6 +128,7 @@ class PortalServicesController extends Controller
])->paginate(20)->withQueryString();
$pageResult->getCollection()->transform(fn ($resource) => app(PublicDocumentPresentation::class)->resource($resource));
$props['resources'] = $pageResult;
app(PublicSearchMetrics::class)->candidate($request, 'documents', $pageResult->total(), $filters);
$props['documentTypes'] = (clone $this->resources())->distinct()->orderBy('r.document_type')->pluck('r.document_type');
$props['languages'] = (clone $this->resources())->distinct()->orderBy('r.language')->pluck('r.language');
$props['issuers'] = (clone $this->resources())->whereNotNull('d.issuer')->distinct()->orderBy('d.issuer')->pluck('d.issuer');
@@ -153,6 +155,7 @@ class PortalServicesController extends Controller
$query->where('title', 'ilike', '%'.$filters['q'].'%');
}
$props['partnerships'] = $query->orderByDesc('id')->select(['id', 'title', 'sector', 'province', 'offer', 'seeking'])->paginate(20)->withQueryString();
app(PublicSearchMetrics::class)->candidate($request, 'partnerships', $props['partnerships']->total(), $filters);
}
if ($page === 'desk') {
if (app(VirtualDesk::class)->installed()) {
@@ -6,11 +6,26 @@ use App\Models\Opportunity;
use App\Services\ProactiveSupply;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class ProactiveSupplyController extends Controller
{
public function review(Request $request, ProactiveSupply $service): JsonResponse|Response
{
$service->authorize($request->user());
$v = $request->validate(['record_id' => 'nullable|integer|min:1']);
$record = isset($v['record_id']) ? Opportunity::whereKey($v['record_id'])->firstOrFail() : null;
$data = $service->review($request->user(), $request->all(), $record);
$data['recordId'] = isset($v['record_id']) ? (int) $v['record_id'] : null;
return $request->expectsJson() ? response()->json($data)->header('Cache-Control', 'private, no-store')
: Inertia::render('management/proactive-supply-review', $data);
}
public function index(Request $request, ProactiveSupply $service): JsonResponse
{
$service->authorize($request->user());
$v = $request->validate(['record_id' => 'nullable|integer|min:1']);
$record = isset($v['record_id']) ? Opportunity::whereKey($v['record_id'])->firstOrFail() : null;
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers;
use App\Services\ProductExperiments;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class ProductExperimentController extends Controller
{
public function index(Request $request, ProductExperiments $experiments): Response|JsonResponse
{
$data = $experiments->listing($request->user(), $request->all());
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/experiments/index', $data);
}
public function create(Request $request, ProductExperiments $experiments): Response|JsonResponse
{
return $this->show($request, $experiments);
}
public function show(Request $request, ProductExperiments $experiments, ?string $id = null): Response|JsonResponse
{
$data = $experiments->view($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/experiments/edit', $data);
}
public function store(Request $request, ProductExperiments $experiments): JsonResponse
{
return response()->json($experiments->save($request->user(), null, $request->all()), 201);
}
public function revise(Request $request, ProductExperiments $experiments, string $id): JsonResponse
{
return response()->json($experiments->save($request->user(), $id, $request->all()));
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers;
use App\Services\ProductUsageMetrics;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class ProductUsageMetricsController extends Controller
{
public function index(Request $request, ProductUsageMetrics $metrics): Response|JsonResponse
{
$data = ['report' => $metrics->report($request->user(), $request->all())];
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/product-usage', $data);
}
}
@@ -5,11 +5,14 @@ namespace App\Http\Controllers;
use App\Http\Resources\PublicBusinessOpportunityResource;
use App\Models\ExistingBusiness;
use App\Models\Sector;
use App\Services\BusinessProximitySearch;
use App\Services\CountryCatalogue;
use App\Services\ExistingBusinessClassification;
use App\Services\ExistingBusinessDrafts;
use App\Services\InvestmentGeography;
use App\Services\OpportunityClassification;
use App\Services\PublicBusinessLocations;
use App\Services\PublicSearchMetrics;
use App\Services\SectorOrganization;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
@@ -30,13 +33,7 @@ class PublicBusinessOpportunityController extends Controller
$path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) {
$territoryIds = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
$query->whereExists(fn ($locations) => $locations->selectRaw('1')->from('existing_business_locations as matching_location')
->whereColumn('matching_location.existing_business_id', 'existing_businesses.id')->where('matching_location.is_public', true)
->where(function ($match) use ($territoryIds): void {
foreach (['province_id', 'district_id', 'admin_post_id', 'locality_id'] as $field) {
$match->orWhereIn('matching_location.'.$field, $territoryIds);
}
}));
$query->whereExists(app(PublicBusinessLocations::class)->matchingArea($territoryIds)->selectRaw('1')->whereColumn('l.existing_business_id', 'existing_businesses.id'));
}
if (! empty($filters['q'])) {
$term = trim($filters['q']);
@@ -52,12 +49,21 @@ class PublicBusinessOpportunityController extends Controller
$query->where('existing_businesses.modality', $filters['modality']);
}
$results = PublicBusinessOpportunityResource::collection($query->paginate($filters['per_page'] ?? 12)->appends($filters));
$coverage = null;
if (! empty($filters['near_business'])) {
$proximity = app(BusinessProximitySearch::class);
$coverage = $proximity->constrain($query, $proximity->origin($filters['near_business']), (float) $filters['radius_km']);
} else {
$query->orderByDesc('existing_businesses.published_at')->orderBy('existing_businesses.id');
}
$page = $query->paginate($filters['per_page'] ?? 12)->appends($filters);
app(PublicSearchMetrics::class)->candidate($request, 'businesses', $page->total(), $filters);
$results = PublicBusinessOpportunityResource::collection($page)->additional(['geographicCoverage' => $coverage]);
if ($request->expectsJson()) {
return $results->response();
}
return Inertia::render('investment/business-marketplace', ['results' => $results, 'filters' => $filters, 'territorySelection' => $path,
return Inertia::render('investment/business-marketplace', ['results' => $results, 'filters' => $filters, 'territorySelection' => $path, 'geographicCoverage' => $coverage,
'classificationOptions' => app(OpportunityClassification::class)->options(),
'authorities' => DB::table('authorities')->where('status', 'published')->orderBy('label_pt_mz')->get(['id', 'label_pt_mz'])]);
}
@@ -72,6 +78,7 @@ class PublicBusinessOpportunityController extends Controller
'modality' => ['nullable', Rule::in(ExistingBusinessDrafts::MODALITIES)],
'sector' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
'near_business' => ['nullable', 'required_with:radius_km', 'uuid'], 'radius_km' => ['nullable', 'required_with:near_business', 'numeric', 'min:0.1', 'max:2000'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'],
'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
...app(ExistingBusinessClassification::class)->filterRules(),
@@ -93,6 +100,7 @@ class PublicBusinessOpportunityController extends Controller
}
return Inertia::render('investment/business-opportunity', ['record' => $resource->resolve($request),
'similarRadiusAvailable' => app(BusinessProximitySearch::class)->available($record->id),
'similarCatalogueUrl' => '/negocios?'.http_build_query(array_filter([
'modality' => $record->modality,
'sector' => $record->sector_id,
@@ -119,14 +127,14 @@ class PublicBusinessOpportunityController extends Controller
/** @return Builder<ExistingBusiness> */
private function publishedQuery(): Builder
{
$publicLocations = DB::table('existing_business_locations as locations')
->leftJoin('geo_locations as province', 'province.id', '=', 'locations.province_id')
->leftJoin('geo_locations as district', 'district.id', '=', 'locations.district_id')
->leftJoin('geo_locations as locality', 'locality.id', '=', 'locations.locality_id')
->where('locations.is_public', true)->where('locations.is_primary', true)
$publicLocations = app(PublicBusinessLocations::class)->primary()
->leftJoin('geo_locations as province', 'province.id', '=', 'l.province_id')
->leftJoin('geo_locations as district', 'district.id', '=', 'l.district_id')
->leftJoin('geo_locations as locality', 'locality.id', '=', 'l.locality_id')
->whereNull('province.deleted_at')->whereNull('district.deleted_at')->whereNull('locality.deleted_at')
->select('locations.existing_business_id', 'province.name as province', 'province.id as similar_province_id')
->selectRaw("CASE WHEN locations.precision_level IN ('district','admin_post','locality','exact') THEN district.name ELSE NULL END AS district")
->select('l.existing_business_id', 'l.updated_at as location_recorded_at', 'province.name as province', 'province.id as similar_province_id')
->selectRaw(app(PublicBusinessLocations::class)->sourceProjection())
->selectRaw("CASE WHEN l.precision_level IN ('district','admin_post','locality','exact') THEN district.name ELSE NULL END AS district")
->selectRaw('NULL::text AS locality');
return ExistingBusiness::query()->published()->with('sector:id,name,slug')
@@ -136,6 +144,7 @@ class PublicBusinessOpportunityController extends Controller
'existing_businesses.currency', 'existing_businesses.amount_min', 'existing_businesses.amount_max',
'existing_businesses.available_percentage', 'existing_businesses.published_at', 'existing_businesses.sector_id',
'existing_businesses.owner_user_id', 'existing_businesses.organization_id', 'existing_businesses.version_no', 'existing_businesses.expires_at', 'existing_businesses.status', 'existing_businesses.modality',
'public_location.province', 'public_location.district', 'public_location.locality', 'public_location.similar_province_id']);
'public_location.province', 'public_location.district', 'public_location.locality', 'public_location.similar_province_id',
'public_location.location_recorded_at', 'public_location.location_source_reference', 'public_location.location_source_date']);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers;
use App\Services\OpportunityTechnicalSupport;
use App\Services\TechnicalIncidentHandling;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
final class TechnicalSupportController extends Controller
{
public function index(Request $request, OpportunityTechnicalSupport $support): Response
{
$filters = $request->validate(['page' => ['nullable', 'integer', 'min:1'], 'status' => ['nullable', 'in:all,open,in_progress,resolved'], 'unread' => ['nullable', 'boolean']]);
$status = $filters['status'] ?? 'all';
$unread = $request->boolean('unread');
$incidents = $support->queue($request->user(), $status, $unread);
Inertia::encryptHistory();
return tap(Inertia::render('profiles/technical-support', ['actorId' => $request->user()->id, 'incidents' => $incidents, 'filters' => ['status' => $status, 'unread' => $unread]])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function show(Request $request, string $reference, TechnicalIncidentHandling $handling, OpportunityTechnicalSupport $support): Response
{
$filters = $request->validate(['history_page' => ['nullable', 'integer', 'min:1'], 'page' => ['nullable', 'integer', 'min:1'], 'status' => ['nullable', 'in:all,open,in_progress,resolved'], 'unread' => ['nullable', 'boolean']]);
$data = $handling->detail($request->user(), $reference);
Inertia::encryptHistory();
$query = array_filter(['page' => $request->integer('page') > 1 ? $request->integer('page') : null, 'status' => ($filters['status'] ?? 'all') !== 'all' ? $filters['status'] : null, 'unread' => $request->boolean('unread') ? 1 : null], fn ($v) => $v !== null);
return tap(Inertia::render('profiles/technical-incident', [...$data, 'notice' => $support->notice($request->user(), $reference), 'backUrl' => '/suporte-tecnico/incidentes'.($query !== [] ? '?'.http_build_query($query) : '')])->toResponse($request), static fn (Response $response) => $response->headers->set('Cache-Control', 'private, no-store'));
}
public function decide(Request $request, string $reference, TechnicalIncidentHandling $handling): JsonResponse
{
return response()->json($handling->decide($request->user(), $reference, $request->all()))->header('Cache-Control', 'private, no-store');
}
public function consulted(Request $request, string $reference, OpportunityTechnicalSupport $support): JsonResponse
{
return response()->json($support->markConsulted($request->user(), $reference))->header('Cache-Control', 'private, no-store');
}
}
+143 -40
View File
@@ -7,6 +7,9 @@ use App\Models\InvestmentSubmission;
use App\Models\User;
use App\Services\Ai\InvestorGuidance;
use App\Services\CountryCatalogue;
use App\Services\DeskAttachments;
use App\Services\DeskPaymentEvidence;
use App\Services\DeskServiceAvailability;
use App\Services\InstitutionalLinkMonitor;
use App\Services\InvestmentGeography;
use App\Services\InvestorContextConsumers;
@@ -27,6 +30,7 @@ use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
class VirtualDeskController extends Controller
{
@@ -34,7 +38,7 @@ class VirtualDeskController extends Controller
{
$filters = $request->validate(['q' => 'nullable|string|max:120', 'theme' => 'nullable|string|max:80', 'profile' => 'nullable|string|max:100']);
$installed = $desk->installed();
$services = $installed ? DB::table('desk_service_versions')->where('status', 'published')->orderBy('code')->orderByDesc('version')->get()->unique('code')->map(fn ($s) => ['id' => $s->id, 'version' => $s->version, 'definition' => json_decode($s->definition, true), 'source' => json_decode($s->source, true)])->values() : collect();
$services = $installed ? app(DeskServiceAvailability::class)->current()->get()->map(fn ($s) => ['id' => $s->id, 'version' => $s->version, 'effective_from' => $s->effective_from ?? null, 'definition' => json_decode($s->definition, true), 'source' => json_decode($s->source, true)])->values() : collect();
$services = $services->filter(fn ($s) => (! ($filters['q'] ?? '') || str_contains(mb_strtolower(json_encode($s['definition'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)), mb_strtolower($filters['q'])))
&& (! ($filters['theme'] ?? '') || $s['definition']['theme'] === $filters['theme']) && (! ($filters['profile'] ?? '') || $s['definition']['profile'] === $filters['profile']))->values();
$page = max(1, (int) $request->query('page', 1));
@@ -49,7 +53,7 @@ class VirtualDeskController extends Controller
$actor = $request->user();
$organizations = DB::table('memberships')->where('user_id', $actor->id)->whereNull('deleted_at')->pluck('organization_id');
$cases = DB::table('desk_cases')->where(fn ($q) => $q->where(fn ($p) => $p->where('user_id', $actor->id)->whereNull('organization_id'))->orWhereIn('organization_id', $organizations))->orderByDesc('updated_at')->get(['id', 'title', 'version', 'updated_at']);
$queue = DB::table('desk_requests as r')->join('desk_cases as c', 'c.id', '=', 'r.case_id')->whereNotIn('r.status', ['draft', 'cancelled'])->when(! $actor->can('manage-portal'), fn ($q) => $q->where('r.analyst_id', $actor->id))
$queue = DB::table('desk_requests as r')->join('desk_cases as c', 'c.id', '=', 'r.case_id')->whereNull('c.closed_at')->whereNotIn('r.status', ['draft', 'cancelled', 'closed'])->when(! $actor->can('manage-portal'), fn ($q) => $q->where('r.analyst_id', $actor->id))
->orderByRaw('r.due_at ASC NULLS LAST')->get(['r.id', 'r.protocol', 'r.status', 'r.version', 'r.analyst_id', 'r.due_at', 'c.title']);
$data = ['cases' => $cases, 'queue' => $queue, 'notifications' => DB::table('desk_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(100)->get(),
'legacyRequests' => DB::table('interest_requests')->where('metadata->portal_user_id', $actor->id)->whereNull('deleted_at')->orderByDesc('created_at')->get(['id', 'reference', 'status', 'created_at']),
@@ -86,9 +90,10 @@ class VirtualDeskController extends Controller
'legacyRequest' => ($case->interest_request_id ?? null) ? DB::table('interest_requests as r')->leftJoin('users as u', 'u.id', '=', 'r.assigned_to')->where('r.id', $case->interest_request_id)->whereNull('r.deleted_at')
->when(! $request->user()->can('manage-portal'), fn ($q) => $q->where('r.metadata->portal_user_id', $request->user()->id))
->first(['r.id', 'r.reference', 'r.status', 'r.next_action', 'r.next_action_due_at', 'u.name as responsible_name']) : null,
'owner' => $owner, 'canWrite' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'),
'owner' => $owner, 'canWrite' => ! $case->closed_at && $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'),
'canChangeState' => (int) $case->user_id === $request->user()->id && $desk->owns($request->user(), $case), 'activity' => $owner ? $desk->caseActivity($case) : null,
'requests' => DB::table('desk_requests')->where('case_id', $id)->when(! $owner && ! $request->user()->can('manage-portal'), fn ($q) => $q->where('analyst_id', $request->user()->id))->orderByDesc('updated_at')->get(['id', 'status', 'protocol', 'service_version_id', 'version']),
'services' => DB::table('desk_service_versions')->where('status', 'published')->orderByDesc('version')->get(['id', 'code', 'version', 'definition'])->unique('code')->values()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'version' => $s->version]),
'services' => app(DeskServiceAvailability::class)->current()->get()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'version' => $s->version, 'effective_from' => $s->effective_from ?? null]),
'documents' => $this->documents($id, $request->user()), 'events' => $this->events($id, $desk, $request->user()), 'stepStates' => config('virtual-desk.step_states'), 'states' => config('virtual-desk.request_states'),
'links' => DB::table('desk_institutional_links')->get()->map(fn ($link) => [...(array) $link, 'monitor' => app(InstitutionalLinkMonitor::class)->status($link)]), 'source' => config('virtual-desk.source'), 'support' => config('portal.support'),
'guidance' => app(InvestorGuidance::class)->installed() ? app(InvestorGuidance::class)->view($request->user(), $id) : null];
@@ -101,7 +106,46 @@ class VirtualDeskController extends Controller
{
return DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $case)->whereNull('d.deleted_at')
->when(! app(VirtualDesk::class)->owns($actor, app(VirtualDesk::class)->case($actor, $case), false) && ! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->whereNull('l.request_id')->orWhereIn('l.request_id', DB::table('desk_requests')->where('analyst_id', $actor->id)->select('id'))))
->get(['l.id', 'l.document_id', 'l.request_id', 'l.category', 'l.stage', 'l.source_reference', 'l.valid_until', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.sha256']);
->get(['l.id', 'l.document_id', 'l.request_id', 'l.category', 'l.stage', 'l.source_reference', 'l.valid_until', 'd.file_name', 'd.status', 'd.malware_scan_status', 'd.sha256', 'd.media_type', 'd.size_bytes', 'd.version_no']);
}
public function availableDocuments(Request $request, string $id, VirtualDesk $desk): JsonResponse
{
$case = $desk->case($request->user(), $id, true);
abort_if($case->closed_at, 409, 'Dossier encerrado.');
$data = $request->validate(['q' => 'nullable|string|max:120', 'offset' => 'nullable|integer|min:0']);
$offset = (int) ($data['offset'] ?? 0);
$actor = $request->user();
// Match the existing reuse authority: this dossier, own personal dossiers, or own uploads.
// A document's uploader does not inherit another dossier's reference or private context.
$sources = DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')
->where(fn ($q) => $q->where('c.id', $id)->orWhere(fn ($p) => $p->where('c.user_id', $actor->id)->whereNull('c.organization_id')))
->select(['l.id', 'l.document_id', 'l.category', 'l.stage', 'l.source_reference', 'l.valid_until', 'c.title as origin_label']);
$query = DB::table('documents as d')->leftJoinSub($sources, 's', fn ($q) => $q->on('s.document_id', '=', 'd.id'))
->whereNull('d.deleted_at')->where(fn ($q) => $q->where('d.uploaded_by', $actor->id)->orWhereNotNull('s.id'));
$search = trim($data['q'] ?? '');
if ($search !== '') {
$like = '%'.addcslashes($search, '\\%_').'%';
$query->where(fn ($q) => $q->where('d.file_name', 'ilike', $like)->orWhere('s.source_reference', 'ilike', $like)->orWhere('s.origin_label', 'ilike', $like));
}
$rows = $query->orderBy('d.file_name')->orderBy('d.id')->orderBy('s.id')->offset($offset)->limit(26)
->get(['d.id as document_id', 'd.file_name', 'd.version_no', 'd.status', 'd.malware_scan_status', 'd.media_type', 'd.size_bytes', 'd.source_system', 's.id', 's.category', 's.stage', 's.source_reference', 's.valid_until', 's.origin_label']);
$items = $rows->take(25)->filter(function ($document) {
try {
app(VerifiedDocumentBinary::class)->authorizeOpportunityAttachment((object) [...(array) $document, 'id' => $document->document_id]);
return true;
} catch (HttpExceptionInterface $exception) {
if ($exception->getStatusCode() !== 403) {
throw $exception;
}
return false;
}
})->map(fn ($document) => Arr::except((array) $document, ['source_system']))->values();
$this->auditRead($request, 'desk_cases', $id);
return response()->json(['documents' => $items, 'next_offset' => $rows->count() > 25 ? $offset + 25 : null], 200, ['Cache-Control' => 'private, no-store']);
}
/** @return Collection<int,array{id:mixed,request_id:mixed,action:mixed,version:mixed,created_at:mixed,name:mixed,payload:array<string,mixed>}> */
@@ -112,6 +156,13 @@ class VirtualDeskController extends Controller
->orderBy('e.event_order')->get(['e.id', 'e.request_id', 'e.action', 'e.payload', 'e.version', 'e.created_at', 'u.name'])->map(fn ($e) => ['id' => $e->id, 'request_id' => $e->request_id, 'action' => $e->action, 'version' => $e->version, 'created_at' => $e->created_at, 'name' => $e->name, 'payload' => $desk->decode($e->payload)]);
}
public function caseState(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$result = $desk->changeCaseState($request->user(), $id, $request->all());
return $request->expectsJson() ? response()->json($result) : back()->with('success', $result['replayed'] ? 'Operação já registada; o estado actual do dossier é apresentado.' : ($result['closed_at'] ? 'Dossier encerrado pelo dono. Os registos continuam disponíveis para consulta.' : 'Dossier reaberto pelo dono. Mantêm-se os estados próprios dos pedidos e das tarefas.'));
}
public function stage(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$case = $desk->stage($request->user(), $id, $request->all());
@@ -147,17 +198,21 @@ class VirtualDeskController extends Controller
}
}
$this->auditRead($request, 'desk_requests', $id);
$caseDocuments = $this->documents($case->id, $request->user());
$paymentEvidence = app(DeskPaymentEvidence::class)->forRequest($id);
$data = ['boundContextRevision' => app(InvestorContextConsumers::class)->requestRevision($row), 'countries' => app(CountryCatalogue::class)->all(), 'toolPrefill' => $desk->requestToolPrefill($request->user(), $row), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)],
'case' => ['id' => $case->id, 'title' => $case->title], 'caseVersion' => $case->version, 'definition' => $definition, 'territoryPaths' => $territoryPaths,
'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true),
'serviceEffectiveFrom' => DB::table('desk_service_versions')->where('id', $row->service_version_id)->selectRaw(app(DeskServiceAvailability::class)->dateProjection())->first()->effective_from,
'owner' => $desk->owns($request->user(), $case), 'manager' => app(ScopedPortalAdministration::class)->deskDecision($request->user(), $row), 'analyst' => (int) $row->analyst_id === $request->user()->id,
'fieldsEditable' => in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => config('virtual-desk.transitions.'.$row->status, []),
'findings' => $desk->findings($payload, $definition), 'ai' => config('virtual-desk.ai'), 'documents' => $this->documents($case->id, $request->user())->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(),
'operationsEditable' => ! $case->closed_at && ! in_array($row->status, ['closed', 'cancelled'], true), 'fieldsEditable' => ! $case->closed_at && in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => $case->closed_at ? [] : config('virtual-desk.transitions.'.$row->status, []), 'returnReasons' => config('product-metrics.return_reasons'),
'findings' => $desk->findings($payload, $definition), 'ai' => config('virtual-desk.ai'), 'documents' => $caseDocuments->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(),
'reusableDocuments' => $desk->owns($request->user(), $case) && ! $case->closed_at ? $caseDocuments->values() : [],
'messages' => DB::table('desk_messages as m')->join('users as u', 'u.id', '=', 'm.actor_id')->where('request_id', $id)->orderBy('m.created_at')->get(['m.id', 'm.actor_id', 'm.body', 'm.created_at', 'u.name'])->map(fn ($m) => [...(array) $m, 'body' => Crypt::decryptString($m->body),
'kind' => $messageKinds[$m->id] ?? 'historical_unclassified']),
'events' => $this->events($case->id, $desk, $request->user())->filter(fn ($e) => $e['request_id'] === $id)->values(),
'revisions' => DB::table('desk_revisions')->where('request_id', $id)->orderByDesc('version')->get(['version', 'snapshot', 'created_at'])->map(fn ($r) => [...(array) $r, 'snapshot' => $desk->decode($r->snapshot)]),
'payments' => DB::table('desk_payments')->where('request_id', $id)->get(),
'payments' => DB::table('desk_payments')->where('request_id', $id)->get()->map(fn ($payment) => [...(array) $payment, 'document_evidence' => $paymentEvidence[$payment->id] ?? null]),
'analysts' => $request->user()->can('manage-portal') ? User::orderBy('name')->get(['id', 'name']) : [],
'territories' => app(InvestmentGeography::class)->publicReferences()->orderBy('name')->get(['id', 'name', 'location_type'])];
@@ -182,7 +237,9 @@ class VirtualDeskController extends Controller
{
abort_unless(app(ScopedPortalAdministration::class)->administrative($request->user()), 403);
// Triage projection deliberately excludes payloads, documents, investor identity and case titles.
$data = ['requests' => DB::table('desk_requests')->whereNotIn('status', ['draft', 'cancelled', 'closed'])->orderBy('created_at')->get(['id', 'protocol', 'status', 'version', 'analyst_id', 'due_at']),
$data = ['requests' => DB::table('desk_requests')->whereNotIn('status', ['draft', 'cancelled', 'closed'])
->whereExists(fn ($q) => $q->selectRaw('1')->from('desk_cases')->whereColumn('desk_cases.id', 'desk_requests.case_id')->whereNull('desk_cases.closed_at'))
->orderBy('created_at')->get(['id', 'protocol', 'status', 'version', 'analyst_id', 'due_at']),
'analysts' => User::whereNotNull('email_verified_at')->orderBy('name')->get(['id', 'name'])];
return $request->expectsJson() ? response()->json($data) : Inertia::render('management/desk-assignments', $data);
@@ -195,8 +252,9 @@ class VirtualDeskController extends Controller
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = DB::table('desk_requests')->where('id', $id)->lockForUpdate()->first();
abort_unless($row !== null, 404);
$case = DB::table('desk_cases')->where('id', $row->case_id)->first();
$case = DB::table('desk_cases')->where('id', $row->case_id)->lockForUpdate()->first();
abort_unless($case !== null, 404);
$desk->assertOpen($case);
if ((int) $row->version !== (int) $data['version']) {
throw ValidationException::withMessages(['version' => 'Este pedido foi actualizado. Recupere a lista e tente novamente.']);
}
@@ -218,7 +276,8 @@ class VirtualDeskController extends Controller
$data = $request->validate(['body' => 'required|string|min:1|max:10000', 'client_key' => 'required|uuid']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = $desk->request($request->user(), $id, false, true);
$case = $desk->case($request->user(), $row->case_id);
$case = $desk->case($request->user(), $row->case_id, false, true);
$desk->assertOpen($case);
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409, 'Pedido encerrado.');
abort_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403);
$existing = DB::table('desk_messages')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('client_key', $data['client_key'])->first();
@@ -260,49 +319,90 @@ class VirtualDeskController extends Controller
public function attach(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['version' => 'required|integer', 'file' => 'nullable|required_without:document_id|file|mimetypes:application/pdf,image/png,image/jpeg,text/plain|max:10240',
'document_id' => 'nullable|required_without:file|uuid', 'category' => 'required|string|max:80', 'stage' => 'nullable|string|max:3', 'request_id' => 'nullable|uuid', 'valid_until' => 'nullable|date_format:Y-m-d', 'source_reference' => 'required|string|max:3000', 'consent' => 'accepted']);
'document_id' => 'nullable|required_without:file|required_with:reuse_link_id|prohibits:file|uuid', 'reuse_link_id' => 'nullable|uuid|prohibits:file', 'category' => 'nullable|required_without:field_key|string|max:80', 'field_key' => 'nullable|string|max:64',
'stage' => 'nullable|string|max:3', 'request_id' => 'nullable|required_with:field_key|uuid', 'valid_until' => 'nullable|date_format:Y-m-d', 'source_reference' => 'nullable|required_without:document_id|string|max:3000', 'consent' => 'accepted']);
$key = null;
$newId = null;
$linkId = null;
$reusedLink = false;
try {
DB::transaction(function () use ($request, $id, $desk, $data, &$key, &$newId) {
DB::transaction(function () use ($request, $id, $desk, $data, &$key, &$newId, &$linkId, &$reusedLink) {
$row = ($data['request_id'] ?? null) ? $desk->request($request->user(), $data['request_id'], true, true) : null;
$case = $desk->case($request->user(), $id, true, true);
abort_if($case->closed_at, 409, 'Dossier encerrado.');
abort_unless((int) $case->version === (int) $data['version'], 409);
if ($data['stage'] ?? null) {
abort_unless(array_key_exists($data['stage'], config('virtual-desk.steps')), 422);
}
if ($data['request_id'] ?? null) {
$row = $desk->request($request->user(), $data['request_id'], true, true);
if ($row) {
abort_unless($row->case_id === $id && in_array($row->status, ['draft', 'returned'], true), 409, 'Anexos de pedidos submetidos são imutáveis; use a devolução para corrigir.');
}
abort_if(DB::table('desk_documents')->where('case_id', $id)->count() >= 100, 422, 'Limite de anexos do dossier atingido.');
if ($request->hasFile('file')) {
$file = $request->file('file');
$hash = hash_file('sha256', $file->getRealPath());
$duplicate = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('d.sha256', $hash)->first();
if ($duplicate) {
return;
$definition = $row ? $desk->definition($row) : [];
$field = ($data['field_key'] ?? null) ? app(DeskAttachments::class)->field($definition, $data['field_key']) : null;
if ($field) {
if (isset($data['stage']) && $data['stage'] !== $definition['stage']) {
throw ValidationException::withMessages(['stage' => 'A utilização nesta pergunta pertence à etapa do pedido; a origem do documento é conservada separadamente.']);
}
$newId = (string) Str::uuid();
$key = $file->storeAs('virtual-desk/'.$id, $newId, 'local');
abort_unless(is_string($key) && $key !== '', 500);
DB::table('documents')->insert(['id' => $newId, 'source_system' => 'virtual-desk', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
'sha256' => $hash, 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id, 'status' => 'quarantined', 'data_classification' => 'confidential']);
$documentId = $newId;
} else {
$documentId = $data['document_id'];
$document = DB::table('documents')->where('id', $documentId)->whereNull('deleted_at')->first();
abort_unless($document !== null, 404);
$owned = (int) $document->uploaded_by === $request->user()->id || DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')->where('l.document_id', $documentId)->where('c.user_id', $request->user()->id)->whereNull('c.organization_id')->exists();
abort_unless($owned, 403, 'Não há autorização para reutilizar este documento.');
if (DB::table('desk_documents')->where('case_id', $id)->where('document_id', $documentId)->exists()) {
return;
abort_unless(app(DeskAttachments::class)->scopedLinksInstalled(), 409, 'Instale a actualização dos vínculos documentais para este formulário.');
if ($row->status === 'returned' && ! in_array($data['field_key'], json_decode($row->editable_fields ?? '[]', true), true)) {
throw ValidationException::withMessages(['field_key' => 'Esta pergunta não está autorizada para correcção.']);
}
}
DB::table('desk_documents')->insert(['id' => (string) Str::uuid(), 'case_id' => $id, 'document_id' => $documentId, 'request_id' => $data['request_id'] ?? null,
'category' => $data['category'], 'stage' => $data['stage'] ?? null, 'valid_until' => $data['valid_until'] ?? null, 'source_reference' => $data['source_reference'], 'linked_by' => $request->user()->id]);
$document = null;
if ($request->hasFile('file')) {
$file = $request->file('file');
if ($field) {
app(DeskAttachments::class)->checkMetadata((object) ['media_type' => $file->getMimeType(), 'size_bytes' => $file->getSize()], $field, 'file');
}
$hash = hash_file('sha256', $file->getRealPath());
$document = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->whereNull('d.deleted_at')->where('d.sha256', $hash)->first(['d.*']);
if (! $document) {
abort_if(DB::table('desk_documents')->where('case_id', $id)->distinct()->count('document_id') >= 100, 422, 'Limite de ficheiros distintos do dossier atingido.');
$newId = (string) Str::uuid();
$key = $file->storeAs('virtual-desk/'.$id, $newId, 'local');
abort_unless(is_string($key) && $key !== '', 500);
DB::table('documents')->insert(['id' => $newId, 'source_system' => 'virtual-desk', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
'sha256' => $hash, 'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id, 'status' => 'quarantined', 'data_classification' => 'confidential']);
$document = DB::table('documents')->where('id', $newId)->firstOrFail();
}
} else {
$document = DB::table('documents')->where('id', $data['document_id'])->whereNull('deleted_at')->first();
abort_unless($document !== null, 404);
$owned = (int) $document->uploaded_by === $request->user()->id || DB::table('desk_documents')->where('case_id', $id)->where('document_id', $document->id)->exists()
|| DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')->where('l.document_id', $document->id)->where('c.user_id', $request->user()->id)->whereNull('c.organization_id')->exists();
abort_unless($owned, 403, 'Não há autorização para reutilizar este documento.');
}
if ($field) {
app(DeskAttachments::class)->checkMetadata($document, $field, 'document_id', true);
}
app(VerifiedDocumentBinary::class)->authorizeOpportunityAttachment($document);
$existing = DB::table('desk_documents')->where('case_id', $id)->where('document_id', $document->id)
->where(fn ($q) => ($data['request_id'] ?? null) ? $q->whereNull('request_id')->orWhere('request_id', $data['request_id']) : $q->whereNull('request_id'))->orderByRaw('request_id IS NULL DESC')->first();
if ($existing) {
$linkId = $existing->id;
$reusedLink = true;
return;
}
if (! app(DeskAttachments::class)->scopedLinksInstalled() && DB::table('desk_documents')->where('case_id', $id)->where('document_id', $document->id)->exists()) {
throw ValidationException::withMessages(['document_id' => 'Instale a actualização dos vínculos para reutilizar o ficheiro noutro pedido, preservando o âmbito anterior.']);
}
$inCase = DB::table('desk_documents')->where('case_id', $id)->where('document_id', $document->id)->exists();
abort_if(! $inCase && DB::table('desk_documents')->where('case_id', $id)->distinct()->count('document_id') >= 100, 422, 'Limite de ficheiros distintos do dossier atingido.');
$sourceLink = DB::table('desk_documents as l')->join('desk_cases as c', 'c.id', '=', 'l.case_id')->where('l.document_id', $document->id)
->where(fn ($q) => $q->where('l.case_id', $id)->orWhere(fn ($p) => $p->where('c.user_id', $request->user()->id)->whereNull('c.organization_id')))
->when($data['reuse_link_id'] ?? null, fn ($q, $sourceId) => $q->where('l.id', $sourceId))->orderBy('l.created_at')->orderBy('l.id')->first(['l.*']);
abort_if(($data['reuse_link_id'] ?? null) && ! $sourceLink, 403, 'Não há autorização para reutilizar esta referência documental.');
$source = $data['source_reference'] ?? $sourceLink?->source_reference;
if (! is_string($source) || trim($source) === '') {
throw ValidationException::withMessages(['source_reference' => 'Indique a proveniência deste documento; não existe uma referência autorizada para reutilizar.']);
}
$linkId = (string) Str::uuid();
DB::table('desk_documents')->insert(['id' => $linkId, 'case_id' => $id, 'document_id' => $document->id, 'request_id' => $data['request_id'] ?? null,
'category' => $field ? Str::limit($field['label'], 80, '') : $data['category'], 'stage' => $data['stage'] ?? ($field ? $definition['stage'] : $sourceLink?->stage), 'valid_until' => array_key_exists('valid_until', $data) ? $data['valid_until'] : $sourceLink?->valid_until, 'source_reference' => $source, 'linked_by' => $request->user()->id]);
DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]);
$desk->event(DB::table('desk_cases')->where('id', $id)->first(), $request->user(), 'document_linked', ['document_id' => $documentId, 'source' => $data['source_reference'], 'consent' => true]);
$desk->event(DB::table('desk_cases')->where('id', $id)->first(), $request->user(), 'document_linked', ['document_id' => $document->id, 'link_id' => $linkId, 'source' => $source, 'consent' => true, 'field_key' => $data['field_key'] ?? null, 'source_link_id' => $sourceLink?->id,
'reused_source' => $sourceLink ? ['source_reference' => $sourceLink->source_reference, 'category' => $sourceLink->category, 'stage' => $sourceLink->stage, 'valid_until' => $sourceLink->valid_until, 'document_id' => $document->id, 'version_no' => $document->version_no, 'sha256' => trim($document->sha256)] : null], $row);
});
} catch (\Throwable $e) {
if ($key) {
@@ -313,7 +413,10 @@ class VirtualDeskController extends Controller
ScanDocumentForMalware::dispatch($newId)->afterCommit();
}
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Documento privado associado; consulte o estado da análise.');
$available = $this->documents($id, $request->user());
return $request->expectsJson() ? response()->json(['ok' => true, 'link_id' => $linkId, 'reused_link' => $reusedLink, 'case_version' => (int) DB::table('desk_cases')->where('id', $id)->value('version'),
'documents' => $available->filter(fn ($d) => ! ($data['request_id'] ?? null) || ! $d->request_id || $d->request_id === $data['request_id'])->values(), 'reusable_documents' => $desk->owns($request->user(), $desk->case($request->user(), $id)) ? $available->values() : []]) : back()->with('success', $reusedLink ? 'Este documento já estava associado; conservou-se a referência registada.' : 'Documento privado associado; consulte o estado da análise.');
}
public function download(Request $request, string $id, string $document, VirtualDesk $desk): BinaryFileResponse|\Illuminate\Http\Response
@@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Services\DeskServiceAvailability;
use App\Services\DeskServiceEditor;
use App\Services\VirtualDeskInstallation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -79,19 +81,22 @@ class VirtualDeskInstallationController extends Controller
DB::select('select pg_advisory_xact_lock(261008, 2)');
$definition = config('virtual-desk.services.'.$data['code']);
$existing = DB::table('desk_service_versions')->where('code', $data['code'])->orderByDesc('version')->first();
if ($existing && $existing->status === 'published' && json_decode($existing->definition, true) === $definition) {
if ($existing && $existing->status === 'published' && app(DeskServiceEditor::class)->sameDefinition(json_decode($existing->definition, true, 512, JSON_THROW_ON_ERROR), $definition)) {
return $existing;
}
$id = (string) Str::uuid();
$effective = app(DeskServiceAvailability::class)->scheduled() ? now()->toDateString() : null;
DB::table('desk_service_versions')->insert(['id' => $id, 'code' => $data['code'], 'version' => ($existing->version ?? 0) + 1, 'status' => 'published',
...($effective ? ['effective_from' => $effective] : []),
'definition' => json_encode($definition), 'source' => json_encode(config('virtual-desk.source')), 'author_id' => $request->user()->id,
'published_by' => $request->user()->id, 'published_at' => now(), 'created_at' => now(), 'updated_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => 'desk_service_versions', 'entity_id' => $id, 'actor_user_id' => $request->user()->id,
'action' => 'internal_service_published', 'after_data' => json_encode(['code' => $data['code'], 'nature' => 'digital_preparation_not_official_form'])]);
'action' => 'internal_service_published', 'after_data' => json_encode(['code' => $data['code'], 'nature' => 'digital_preparation_not_official_form', 'effective_from' => $effective])]);
return DB::table('desk_service_versions')->where('id', $id)->first();
});
return $request->expectsJson() ? response()->json(['id' => $row->id], 201) : back()->with('success', 'Serviço de preparação interna publicado.');
return $request->expectsJson() ? response()->json(['id' => $row->id, 'effective_from' => $row->effective_from ?? null], 201)
: back()->with('success', 'Serviço de preparação interna publicado'.(($row->effective_from ?? null) ? ' com entrada em vigor em '.$row->effective_from : '').'.');
}
}
@@ -3,14 +3,16 @@
namespace App\Http\Controllers;
use App\Services\Ai\InvestorGuidanceEvidence;
use App\Services\DeskPaymentEvidence;
use App\Services\VerifiedDocumentBinary;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/** Manual operations use the same requests, documents and encrypted audit history. */
class VirtualDeskOperationsController extends Controller
@@ -25,16 +27,19 @@ class VirtualDeskOperationsController extends Controller
abort_unless(! $desk->owns($request->user(), $case, false) && ($request->user()->can('manage-portal') || ($row && (int) $row->analyst_id === $request->user()->id)), 403);
}
private function cleanDocument(string $caseId, string $documentId, ?string $requestId): \stdClass
private function cleanDocument(string $caseId, string $documentId, ?string $requestId, ?string $linkId = null): \stdClass
{
$doc = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')
->where('l.case_id', $caseId)->where('d.id', $documentId)->whereNull('d.deleted_at')
->when($linkId, fn ($q) => $q->where('l.id', $linkId))
->where(fn ($q) => $q->whereNull('l.request_id')->when($requestId, fn ($p) => $p->orWhere('l.request_id', $requestId)))
->first(['d.*', 'l.id as link_id']);
abort_unless($doc !== null && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && trim($doc->sha256) === $doc->malware_scanned_sha256, 422, 'Documento indisponível para conferência: verifique autorização, integridade e análise antimalware.');
$path = realpath(Storage::disk('local')->path($doc->storage_key));
$root = realpath(Storage::disk('local')->path(''));
abort_unless($path && $root && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path) && is_string($hash = hash_file('sha256', $path)) && hash_equals(trim($doc->sha256), $hash), 422, 'O ficheiro original ou a sua integridade não pôde ser confirmado.');
try {
app(VerifiedDocumentBinary::class)->respond($doc);
} catch (HttpExceptionInterface) {
abort(422, 'O original, a sua autorização ou integridade não pôde ser confirmado.');
}
return $doc;
}
@@ -43,15 +48,16 @@ class VirtualDeskOperationsController extends Controller
{
$data = $request->validate(['version' => 'required|integer|min:1', 'request_id' => 'nullable|uuid', 'outcome' => 'required|in:internal_verified,internal_issue,contradiction_identified', 'what_checked' => 'required|string|min:3|max:3000', 'evidence' => 'required|string|min:3|max:5000', 'supports' => 'nullable|array|max:4', 'supports.*' => ['string', Rule::in(InvestorGuidanceEvidence::FIELDS)]]);
DB::transaction(function () use ($request, $id, $document, $desk, $data) {
$case = $desk->case($request->user(), $id, false, true);
abort_unless(! $case->closed_at && (int) $case->version === (int) $data['version'], 409);
$desk->case($request->user(), $id);
$link = DB::table('desk_documents')->where('case_id', $id)->where('id', $document)->first();
abort_unless($link !== null, 404);
$requestId = $link->request_id ?: ($data['request_id'] ?? null);
$row = $requestId ? $desk->request($request->user(), $requestId, false, true) : null;
$case = $desk->case($request->user(), $id, false, true);
abort_unless(! $case->closed_at && (int) $case->version === (int) $data['version'], 409);
abort_if($row && $row->case_id !== $id, 422);
$this->reviewer($request, $desk, $case, $row);
$doc = $this->cleanDocument($id, $link->document_id, $requestId);
$doc = $this->cleanDocument($id, $link->document_id, $requestId, $link->id);
DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]);
$desk->event(DB::table('desk_cases')->where('id', $id)->firstOrFail(), $request->user(), 'document_reviewed', [
...$data, 'document_id' => $doc->id, 'link_id' => $link->id, 'sha256' => trim($doc->sha256), 'document_version' => (int) $doc->version_no, 'reviewer_id' => $request->user()->id,
@@ -64,26 +70,58 @@ class VirtualDeskOperationsController extends Controller
public function payment(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['reference' => 'required|string|max:255', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/', 'numeric', 'gt:0'], 'currency' => 'required|in:MZN,USD,EUR', 'source_reference' => 'required|string|min:3|max:3000', 'document_id' => 'nullable|uuid', 'consent' => 'accepted']);
$data = $request->validate(['reference' => 'required|string|max:255', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/', 'numeric', 'gt:0'], 'currency' => 'required|in:MZN,USD,EUR', 'source_reference' => 'required|string|min:3|max:3000', 'document_id' => 'nullable|uuid', 'document_link_id' => 'nullable|uuid', 'consent' => 'accepted']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = $desk->request($request->user(), $id, true, true);
$case = $desk->case($request->user(), $row->case_id);
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
if ($data['document_id'] ?? null) {
abort_unless(DB::table('desk_documents')->where('case_id', $case->id)->where('document_id', $data['document_id'])->where(fn ($q) => $q->whereNull('request_id')->orWhere('request_id', $id))->exists(), 422);
}
$case = $desk->case($request->user(), $row->case_id, true, true);
abort_if($case->closed_at || in_array($row->status, ['closed', 'cancelled'], true), 409);
$evidence = app(DeskPaymentEvidence::class)->snapshot($case->id, $id, $data);
// References are supplied by the investor; no bank reference or fee is generated.
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['virtual-desk-payment:'.$data['reference']]);
$existing = DB::table('desk_payments')->where('reference', $data['reference'])->first();
if ($existing) {
$parts = explode('.', (string) $data['amount']);
$amount = (ltrim($parts[0], '0') ?: '0').'.'.str_pad($parts[1] ?? '', 2, '0');
abort_unless($existing->request_id === $id && (string) $existing->amount === $amount && $existing->currency === $data['currency'] && $existing->source_reference === $data['source_reference'] && $existing->document_id === ($data['document_id'] ?? null), 409);
$saved = app(DeskPaymentEvidence::class)->forRequest($id)[$existing->id] ?? null;
abort_unless($existing->request_id === $id && (string) $existing->amount === $amount && $existing->currency === $data['currency'] && $existing->source_reference === $data['source_reference'] && $existing->document_id === ($evidence['document_id'] ?? null)
&& (! ($data['document_link_id'] ?? null) || ($saved['link_id'] ?? null) === $data['document_link_id']), 409);
return;
}
$payment = (string) Str::uuid();
DB::table('desk_payments')->insert(['id' => $payment, 'request_id' => $id, 'reference' => $data['reference'], 'amount' => $data['amount'], 'currency' => $data['currency'], 'source_reference' => $data['source_reference'], 'document_id' => $data['document_id'] ?? null, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
$desk->event($case, $request->user(), 'payment_declared', ['payment_id' => $payment, 'consent' => true, 'scope' => 'manual_declaration_no_bank_confirmation'], $row);
DB::table('desk_payments')->insert(['id' => $payment, 'request_id' => $id, 'reference' => $data['reference'], 'amount' => $data['amount'], 'currency' => $data['currency'], 'source_reference' => $data['source_reference'], 'document_id' => $evidence['document_id'] ?? null, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
$desk->event($case, $request->user(), 'payment_declared', ['payment_id' => $payment, 'payment_version' => 1, 'document_evidence' => $evidence, 'consent' => true, 'scope' => 'manual_declaration_no_bank_confirmation'], $row);
});
return $this->result($request);
}
public function paymentDocument(Request $request, string $id, string $payment, VirtualDesk $desk): JsonResponse|RedirectResponse
{
$data = $request->validate(['version' => 'required|integer|min:1', 'document_link_id' => 'required|uuid', 'reason' => 'required|string|min:3|max:3000', 'consent' => 'accepted', 'client_key' => 'required|uuid']);
$data['version'] = (int) $data['version'];
$data['consent'] = true;
DB::transaction(function () use ($request, $id, $payment, $desk, $data) {
$row = $desk->request($request->user(), $id, true, true);
$case = $desk->case($request->user(), $row->case_id, true, true);
abort_if($case->closed_at || in_array($row->status, ['closed', 'cancelled'], true), 409);
$record = DB::table('desk_payments')->where('id', $payment)->where('request_id', $id)->lockForUpdate()->first();
abort_unless($record !== null, 404);
foreach (DB::table('desk_events')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('action', 'payment_document_linked')->get(['payload']) as $event) {
$saved = $desk->decode($event->payload);
if (($saved['client_key'] ?? null) === $data['client_key']) {
abort_unless(($saved['payment_id'] ?? null) === $payment && ($saved['operation'] ?? null) === $data, 409, 'A operação já foi utilizada com outros dados. Inicie uma nova associação.');
return;
}
}
abort_unless((int) $record->version === (int) $data['version'] && in_array($record->status, ['pending', 'internal_issue'], true), 409, 'O registo mudou ou já está conferido. Actualize antes de associar outro comprovativo.');
$evidence = app(DeskPaymentEvidence::class)->snapshot($case->id, $id, $data);
abort_unless($evidence !== null, 422);
$previous = app(DeskPaymentEvidence::class)->forRequest($id)[$payment] ?? null;
DB::table('desk_payments')->where('id', $payment)->update(['document_id' => $evidence['document_id'], 'status' => 'pending', 'reason' => null, 'verified_by' => null, 'version' => $record->version + 1, 'updated_at' => now()]);
$desk->event($case, $request->user(), 'payment_document_linked', ['payment_id' => $payment, 'payment_version' => $record->version + 1, 'previous_document_id' => $record->document_id, 'previous_document_evidence' => $previous,
'document_evidence' => $evidence, 'client_key' => $data['client_key'], 'operation' => $data, 'reason' => $data['reason'], 'consent' => true, 'scope' => 'documentary_correction_no_amount_change_or_payment_confirmation'], $row);
});
return $this->result($request);
@@ -94,19 +132,21 @@ class VirtualDeskOperationsController extends Controller
$data = $request->validate(['version' => 'required|integer|min:1', 'status' => 'required|in:internal_verified,internal_issue', 'reason' => 'required|string|min:3|max:3000', 'evidence' => 'required|string|min:3|max:5000']);
DB::transaction(function () use ($request, $id, $payment, $desk, $data) {
$row = $desk->request($request->user(), $id, false, true);
$case = $desk->case($request->user(), $row->case_id);
$case = $desk->case($request->user(), $row->case_id, false, true);
$this->reviewer($request, $desk, $case, $row);
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
abort_if($case->closed_at || in_array($row->status, ['closed', 'cancelled'], true), 409);
$record = DB::table('desk_payments')->where('request_id', $id)->where('id', $payment)->lockForUpdate()->first();
abort_unless($record !== null, 404);
abort_unless((int) $record->version === (int) $data['version'], 409);
$evidence = app(DeskPaymentEvidence::class)->forRequest($id)[$payment] ?? null;
$doc = null;
if ($data['status'] === 'internal_verified') {
abort_unless($record->document_id, 422, 'A conferência exige um comprovativo autorizado.');
$doc = $this->cleanDocument($case->id, $record->document_id, $id);
$doc = $this->cleanDocument($case->id, $record->document_id, $id, $evidence['link_id'] ?? null);
abort_if($evidence && ($evidence['document_id'] !== $doc->id || $evidence['document_version'] !== (int) $doc->version_no || ! hash_equals($evidence['sha256'], trim($doc->sha256))), 422, 'O original já não corresponde ao comprovativo associado. Registe a correcção antes de conferir.');
}
DB::table('desk_payments')->where('id', $payment)->update(['status' => $data['status'], 'reason' => $data['reason'], 'verified_by' => $request->user()->id, 'version' => $record->version + 1, 'updated_at' => now()]);
$desk->event($case, $request->user(), 'payment_reviewed', [...$data, 'payment_id' => $payment, 'document_id' => $doc?->id, 'sha256' => $doc ? trim($doc->sha256) : null, 'reviewer_id' => $request->user()->id, 'scope' => 'internal_proof_check_no_bank_confirmation'], $row);
$desk->event($case, $request->user(), 'payment_reviewed', [...$data, 'payment_id' => $payment, 'payment_version' => $record->version + 1, 'document_id' => $record->document_id, 'document_evidence' => $evidence, 'sha256' => $doc ? trim($doc->sha256) : null, 'reviewer_id' => $request->user()->id, 'scope' => 'internal_proof_check_no_bank_confirmation'], $row);
});
return $this->result($request);
@@ -117,9 +157,9 @@ class VirtualDeskOperationsController extends Controller
$data = $request->validate(['channel' => 'required|in:phone,email,sms,whatsapp,in_person', 'direction' => 'required|in:received,sent', 'occurred_at' => 'required|date|before_or_equal:now', 'summary' => 'required|string|min:3|max:5000', 'evidence' => 'required|string|min:3|max:3000', 'client_key' => 'required|uuid']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = $desk->request($request->user(), $id, false, true);
$case = $desk->case($request->user(), $row->case_id);
$case = $desk->case($request->user(), $row->case_id, false, true);
abort_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403);
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
abort_if($case->closed_at || in_array($row->status, ['closed', 'cancelled'], true), 409);
foreach (DB::table('desk_events')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('action', 'manual_communication_recorded')->get(['payload']) as $event) {
$saved = $desk->decode($event->payload);
if (($saved['client_key'] ?? null) === $data['client_key']) {
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use App\Services\PublicSearchMetrics;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class CollectPublicSearchMetrics
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$candidate = $request->attributes->get(PublicSearchMetrics::ATTRIBUTE);
$request->attributes->remove(PublicSearchMetrics::ATTRIBUTE);
$prefetch = str_contains(strtolower($request->header('Purpose', '').' '.$request->header('Sec-Purpose', '')), 'prefetch') || $request->header('X-Inertia-Prefetch') === 'true';
$privacySignal = $request->header('DNT') === '1' || $request->header('Sec-GPC') === '1';
if ($request->isMethod('GET') && $response->getStatusCode() === 200 && ! $prefetch && ! $privacySignal && is_array($candidate) && isset($candidate['channel'], $candidate['empty']) && is_string($candidate['channel']) && is_bool($candidate['empty'])) {
app(PublicSearchMetrics::class)->record($candidate['channel'], $candidate['empty']);
}
return $response;
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Resources;
use App\Services\ExistingBusinessClassification;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -24,6 +25,10 @@ class PublicBusinessOpportunityResource extends JsonResource
'amount_max' => data_get($this->resource, 'amount_max'),
'available_percentage' => data_get($this->resource, 'available_percentage'),
'published_at' => data_get($this->resource, 'published_at'),
'distance_km' => $this->resource->getAttribute('distance_km') !== null ? round((float) $this->resource->getAttribute('distance_km'), 1) : null,
'location_recorded_at' => $this->resource->getAttribute('location_recorded_at') ? CarbonImmutable::parse($this->resource->getAttribute('location_recorded_at'))->toIso8601String() : null,
'location_source_reference' => $this->resource->getAttribute('location_source_reference'),
'location_source_date' => $this->resource->getAttribute('location_source_date'),
'sector' => $this->whenLoaded('sector', fn () => data_get($this->resource, 'sector')?->only(['name', 'slug'])),
'province' => $this->resource->getAttribute('province'),
'district' => $this->resource->getAttribute('district'),
+112 -15
View File
@@ -2,7 +2,9 @@
namespace App\Jobs;
use App\Services\Malware\DocumentScanAttempts;
use App\Services\Malware\MalwareScanner;
use App\Services\OpportunityTechnicalIncidents;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -11,6 +13,7 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
@@ -25,7 +28,28 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
public int $uniqueFor = 600;
public function __construct(public readonly string $documentId) {}
public ?string $executionId = null;
/** @var array<string,mixed>|null */
public ?array $opportunityContext = null;
/** @param array<string,mixed>|null $opportunityContext */
public function __construct(public readonly string $documentId, ?array $opportunityContext = null)
{
$this->executionId = (string) Str::uuid();
$this->opportunityContext = $opportunityContext;
}
/** Queue UUID also supports payloads serialized before executionId existed. */
public function runIdentifier(): string
{
if ($this->executionId !== null) {
return $this->executionId;
}
$queued = $this->job?->uuid();
return is_string($queued) && Str::isUuid($queued) ? $queued : ($this->executionId = (string) Str::uuid());
}
public function uniqueId(): string
{
@@ -51,11 +75,20 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
if ($row->malware_scan_status === 'infected') {
return null;
}
$attempts = app(DocumentScanAttempts::class);
if ($attempts->completed($row->id, $this->runIdentifier())) {
return null;
}
if ($row->malware_scan_status === 'scanning' && $attempts->owns($row, $this->runIdentifier(), (int) $this->attempts())) {
return null;
}
$attempt = $attempts->start($row, $this->runIdentifier(), (int) $this->attempts());
DB::table('documents')->where('id', $this->documentId)->update([
'status' => 'quarantined', 'malware_scan_status' => 'scanning', 'malware_failure_reason' => null,
'malware_scan_attempts' => DB::raw('malware_scan_attempts + 1'), 'updated_at' => now(),
'malware_scan_attempts' => $attempt, 'updated_at' => now(),
]);
$row->malware_scan_attempts = $attempt;
return $row;
});
@@ -69,22 +102,32 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
$root = $disk->path('');
$path = $disk->path($document->storage_key);
$temporary = null;
$operationFailed = false;
try {
if (($document->storage_encryption ?? null) === 'laravel') {
$temporary = tempnam($root, 'scan-private-');
if ($temporary === false) {
throw new \RuntimeException('Private scan workspace unavailable');
}
chmod($temporary, 0600);
file_put_contents($temporary, Crypt::decryptString($disk->get($document->storage_key)));
if (! @chmod($temporary, 0600)) {
throw new \RuntimeException('Private scan workspace permissions unavailable');
}
$plain = Crypt::decryptString($disk->get($document->storage_key));
if (file_put_contents($temporary, $plain) !== strlen($plain)) {
throw new \RuntimeException('Private scan workspace write incomplete');
}
unset($plain);
$path = $temporary;
}
$beforeHash = $this->fileHash($path);
if ($beforeHash === null || ! hash_equals(trim($document->sha256), $beforeHash)) {
$this->record($document, [
'result' => 'inconclusive', 'reason_code' => 'file_hash_mismatch', 'engine' => 'ClamAV',
$applied = $this->record($document, [
'result' => 'inconclusive', 'reason_code' => 'file_hash_mismatch', 'engine' => 'not_run',
'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => 0, 'detail' => null,
], $startedAt, 'inconclusive');
if ($applied) {
$this->notify('scan_inconclusive');
}
return;
}
@@ -99,10 +142,29 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
}
$result['file_sha256'] = $afterHash;
$this->record($document, $result, $startedAt, $result['result']);
$applied = $this->record($document, $result, $startedAt, $result['result']);
if (in_array($applied, ['inconclusive', 'failed'], true)) {
$this->notify($applied === 'failed' ? 'scan_worker_failed' : 'scan_inconclusive');
}
} catch (Throwable $error) {
$operationFailed = true;
throw $error;
} finally {
if (is_string($temporary) && is_file($temporary)) {
unlink($temporary);
try {
$removed = @unlink($temporary);
} catch (Throwable) {
$removed = false;
}
if (! $removed) {
try {
Log::warning('Private scan workspace cleanup requires verification.', ['reference' => $this->runIdentifier(), 'error_code' => 'scan_cleanup_unconfirmed']);
} catch (Throwable) {
}
if (! $operationFailed) {
$this->notify('scan_cleanup_unconfirmed');
}
}
}
}
}
@@ -113,11 +175,38 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
if (! $document || $document->status === 'deleted' || $document->deleted_at !== null) {
return;
}
if ($document->malware_scan_status === 'pending') {
// A failure before claiming the row cannot overwrite another scan result.
$this->notify('scan_worker_failed');
$this->record($document, [
'result' => 'failed', 'reason_code' => 'scanner_unavailable_after_retries', 'engine' => 'ClamAV',
return;
}
if ($document->malware_scan_status !== 'scanning' || ! app(DocumentScanAttempts::class)->owns($document, $this->runIdentifier(), (int) $this->attempts())) {
return;
}
$descriptor = app(DocumentScanAttempts::class)->descriptor($document, $this->runIdentifier(), (int) $this->attempts());
if ($descriptor === null) {
$this->notify('scan_worker_failed');
return;
}
$document->sha256 = $descriptor['sha256'];
$document->size_bytes = $descriptor['size_bytes'];
$applied = $this->record($document, [
'result' => 'failed', 'reason_code' => 'scan_failed_after_retries', 'engine' => 'unknown',
'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => null, 'detail' => null,
], now(), 'failed');
if ($applied) {
$this->notify('scan_worker_failed');
}
}
private function notify(string $errorCode): void
{
if ($this->opportunityContext !== null) {
app(OpportunityTechnicalIncidents::class)->recordScan($this->opportunityContext, $this->runIdentifier(), $errorCode);
}
}
private function fileHash(string $path): ?string
@@ -130,12 +219,17 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
}
/** @param array<string,mixed> $result */
private function record(\stdClass $document, array $result, \DateTimeInterface $startedAt, string $state): void
private function record(\stdClass $document, array $result, \DateTimeInterface $startedAt, string $state): ?string
{
DB::transaction(function () use ($document, $result, $startedAt, $state): void {
return DB::transaction(function () use ($document, $result, $startedAt, $state): ?string {
$locked = DB::table('documents')->where('id', $this->documentId)->lockForUpdate()->first();
if (! $locked || $locked->status === 'deleted' || $locked->deleted_at !== null) {
return;
return null;
}
if ($locked->malware_scan_status !== 'scanning' || ! app(DocumentScanAttempts::class)->owns($locked, $this->runIdentifier(), (int) $this->attempts(), (int) $document->malware_scan_attempts)) {
app(DocumentScanAttempts::class)->discarded($locked, $this->runIdentifier(), (int) $this->attempts(), (int) $document->malware_scan_attempts);
return null;
}
$sameBytes = hash_equals(trim($document->sha256), trim((string) $locked->sha256));
@@ -160,13 +254,16 @@ class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
'updated_at' => $finishedAt,
]);
DB::table('document_malware_scans')->insert([
'id' => $scanId, 'document_id' => $this->documentId, 'file_sha256' => trim($locked->sha256),
'id' => $scanId, 'document_id' => $this->documentId, 'file_sha256' => trim($document->sha256),
'result' => $finalState, 'reason_code' => $reason,
'engine' => $result['engine'] ?? 'ClamAV', 'engine_version' => $result['engine_version'] ?? 'unknown',
'signatures_at' => $result['signatures_at'] ?? null, 'started_at' => $startedAt,
'finished_at' => $finishedAt, 'duration_ms' => $result['duration_ms'] ?? null,
'file_size_bytes' => $locked->size_bytes ?? null, 'created_at' => $finishedAt, 'updated_at' => $finishedAt,
'file_size_bytes' => $document->size_bytes ?? null, 'created_at' => $finishedAt, 'updated_at' => $finishedAt,
]);
app(DocumentScanAttempts::class)->finish($document, $this->runIdentifier(), (int) $this->attempts(), $scanId, $state === 'failed');
return $finalState;
});
}
}
+21
View File
@@ -3,6 +3,8 @@
namespace App\Models;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Schema;
/**
* @property string|null $lead_id
@@ -13,4 +15,23 @@ class CrmTask extends CrmRecord
protected $table = 'tasks';
protected $casts = ['due_at' => 'immutable_datetime'];
/**
* @param Builder<CrmTask> $query
* @return Builder<CrmTask>
*/
public function scopeAvailableForWork(Builder $query): Builder
{
$query->whereNull('tasks.deleted_at')->whereIn('tasks.status', ['open', 'in_progress']);
if (Schema::hasColumn('tasks', 'desk_case_id')) {
if (Schema::hasColumn('desk_cases', 'closed_at')) {
$query->where(fn ($q) => $q->whereNull('tasks.desk_case_id')->orWhereExists(fn ($desk) => $desk->selectRaw('1')->from('desk_cases')
->whereColumn('desk_cases.id', 'tasks.desk_case_id')->whereNull('desk_cases.closed_at')));
} else {
$query->whereNull('tasks.desk_case_id');
}
}
return $query;
}
}
+3 -2
View File
@@ -69,7 +69,8 @@ final class AssistiveRules
foreach (Lead::whereIn('data_classification', ['public', 'internal'])->whereNull('owner_user_id')->orderBy('id')->limit(20)->get(['id']) as $lead) {
$alerts[] = ['record_type' => 'lead', 'record_id' => $lead->id, 'rule' => 'missing_owner', 'source_url' => '/management/crm/leads/'.$lead->id];
}
foreach (Lead::whereIn('data_classification', ['public', 'internal'])->whereIn('status', ['new', 'qualified'])->whereDoesntHave('tasks', fn ($q) => $q->whereIn('status', ['open', 'in_progress']))
foreach (Lead::whereIn('data_classification', ['public', 'internal'])->whereIn('status', ['new', 'qualified'])
->whereNotExists(CrmTask::availableForWork()->whereColumn('tasks.lead_id', 'leads.id')->selectRaw('1')->toBase())
->orderBy('id')->limit(20)->get(['id']) as $lead) {
$alerts[] = ['record_type' => 'lead', 'record_id' => $lead->id, 'rule' => 'missing_next_action', 'source_url' => '/management/crm/leads/'.$lead->id];
}
@@ -91,7 +92,7 @@ final class AssistiveRules
/** @return list<array<string, mixed>> */
public function sla(): array
{
return array_values(CrmTask::whereIn('data_classification', ['public', 'internal'])->whereIn('status', ['open', 'in_progress'])->where('due_at', '<', now())->orderBy('due_at')->limit(50)
return array_values(CrmTask::whereIn('data_classification', ['public', 'internal'])->availableForWork()->where('due_at', '<', now())->orderBy('due_at')->limit(50)
->get()->map(fn ($task) => [
'record_type' => 'task', 'record_id' => $task->id, 'rule' => 'overdue_open_task',
'due_at' => $task->getAttribute('due_at')?->toIso8601String(), 'lead_id' => $task->lead_id,
+10 -2
View File
@@ -4,8 +4,10 @@ namespace App\Services\Ai;
use App\Models\CrmTask;
use App\Models\InvestmentSubmission;
use App\Models\Lead;
use App\Models\SimulationFormDraft;
use App\Models\User;
use App\Services\CrmNextAction;
use App\Services\InvestmentGeography;
use App\Services\VirtualDesk;
use Illuminate\Support\Arr;
@@ -152,7 +154,7 @@ class InvestorGuidance
->get(['t.id', 't.title', 't.status', 't.due_at', 't.version_no', 't.desk_request_id', 't.accepted_by', 't.accepted_at', 't.assignee_user_id', 'u.name as assignee_name', 't.external_sync_status']);
return ['version' => $case->version, 'rules_version' => InvestorGuidanceRules::VERSION, 'computed_at' => now()->toIso8601String(),
...$context, 'phases' => $phases, 'tasks' => $tasks, 'actions' => $actions, 'can_write' => $this->desk->owns($actor, $case),
...$context, 'phases' => $phases, 'tasks' => $tasks, 'actions' => $actions, 'can_write' => ! $case->closed_at && $this->desk->owns($actor, $case), 'can_update_tasks' => ! $case->closed_at,
'external_ai' => ['status' => 'not_configured', 'model' => null, 'consulted' => []],
'calculator_versions' => ['business_financial_plan' => hash_file('sha256', resource_path('js/lib/business-financial-plan.ts')), 'financial_calculators' => hash_file('sha256', resource_path('js/lib/financial-calculators.ts')), 'project_dossier' => hash_file('sha256', resource_path('js/lib/project-dossier.ts'))],
'crm' => ['internal' => 'available', 'external' => 'out_of_scope', 'reason' => 'O acompanhamento usa o CRM interno existente. A sincronização externa está fora desta fase; nenhum contacto, mensagem ou documento é transmitido.']];
@@ -235,10 +237,16 @@ class InvestorGuidance
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'status' => ['required', Rule::in(['open', 'in_progress', 'done', 'cancelled'])],
'reason' => 'required|string|min:3|max:3000', 'assignee_user_id' => 'nullable|integer|exists:users,id', 'due_at' => 'nullable|date|after:now'])->validate();
DB::transaction(function () use ($actor, $id, $taskId, $data) {
$case = $this->desk->case($actor, $id);
$case = $this->desk->case($actor, $id, false, true);
$this->desk->assertOpen($case);
$candidate = CrmTask::where('desk_case_id', $id)->whereKey($taskId)->firstOrFail();
// Match CRM lock order, so two response channels cannot finish each other's only replacement.
$lead = $candidate->lead_id === null ? null : Lead::withTrashed()->whereKey($candidate->lead_id)->lockForUpdate()->firstOrFail();
$task = CrmTask::where('desk_case_id', $id)->whereKey($taskId)->lockForUpdate()->firstOrFail();
abort_unless($task->lead_id === $candidate->lead_id, 409, 'O acompanhamento associado mudou. Recupere a tarefa actual.');
abort_unless($actor->can('manage-portal') || (int) $task->getAttribute('assignee_user_id') === $actor->id, 403);
abort_unless((int) $task->getAttribute('version_no') === (int) $data['version'], 409);
app(CrmNextAction::class)->assertMayFinish($lead, $task, $data['status']);
$updates = ['status' => $data['status'], 'version_no' => $data['version'] + 1];
if ($data['assignee_user_id'] ?? null) {
abort_unless($actor->can('manage-portal'), 403);
+39 -2
View File
@@ -64,6 +64,7 @@ final class BusinessFormSchema
'location_notes' => ['step' => 5, 'pt' => 'Endereço ou referência (interno)', 'en' => 'Address or reference (internal)', 'type' => 'textarea', 'max' => 2000],
'location_unknown' => ['step' => 5, 'pt' => 'Não conheço a localização exacta', 'en' => 'I do not know the exact location', 'type' => 'select', 'max' => 3, 'options' => ['yes' => 'Sim', 'no' => 'Não'], 'when' => ['geography_scope' => ['specific', 'multiple']]],
'disclosure_precision' => ['step' => 5, 'pt' => 'Precisão de divulgação', 'en' => 'Disclosure precision', 'type' => 'select', 'max' => 30, 'options' => ['exact' => 'Localização exacta (sujeita a aprovação)', 'approximate' => 'Localização aproximada', 'administrative' => 'Apenas território administrativo'], 'when' => ['geography_scope' => ['specific', 'multiple']]],
...self::pointFields(),
'asset_notes' => ['step' => 6, 'pt' => 'Resumo dos activos e direitos declarados', 'en' => 'Declared assets and rights summary', 'type' => 'textarea', 'max' => 2000],
'source_reference' => ['step' => 7, 'pt' => 'Referência da fonte', 'en' => 'Source reference', 'type' => 'textarea', 'max' => 2000],
'source_provider' => ['step' => 7, 'pt' => 'Quem forneceu os dados?', 'en' => 'Who supplied the information?', 'type' => 'text', 'max' => 500],
@@ -79,8 +80,27 @@ final class BusinessFormSchema
];
}
/** @return array<string, list<string|\Closure|In>> */
public static function rules(): array
/** @return array<string, array{step:int,pt:string,en:string,type:string,max:int,options?:array<string,string>,when?:array<string,list<string>>}> */
private static function pointFields(): array
{
$when = ['geography_scope' => ['specific', 'multiple'], 'disclosure_precision' => ['exact'], 'location_unknown' => ['no']];
$active = [...$when, 'location_point_enabled' => ['yes']];
return [
'location_point_enabled' => ['step' => 5, 'pt' => 'Preparar um ponto exacto para publicação e pesquisa por raio?', 'en' => 'Prepare an exact point for publication and radius searches?', 'type' => 'select', 'max' => 3, 'options' => ['yes' => 'Sim — o ponto substituirá a localização primária na publicação aprovada', 'no' => 'Não — conservar o cadastro de localizações existente'], 'when' => $when],
'location_point_territory' => ['step' => 5, 'pt' => 'Localização já declarada a que pertence o ponto primário', 'en' => 'Previously declared location for the primary point', 'type' => 'point_territory', 'max' => 150, 'when' => [...$active, 'geography_scope' => ['multiple']]],
'location_latitude' => ['step' => 5, 'pt' => 'Latitude WGS84 (graus decimais, -90 a 90)', 'en' => 'WGS84 latitude (decimal degrees, -90 to 90)', 'type' => 'decimal', 'max' => 16, 'when' => $active],
'location_longitude' => ['step' => 5, 'pt' => 'Longitude WGS84 (graus decimais, -180 a 180)', 'en' => 'WGS84 longitude (decimal degrees, -180 to 180)', 'type' => 'decimal', 'max' => 16, 'when' => $active],
'location_point_source_mode' => ['step' => 5, 'pt' => 'Fonte das coordenadas', 'en' => 'Coordinate source', 'type' => 'select', 'max' => 20, 'options' => ['listing' => 'Reutilizar referência e data da etapa Fontes, se também sustentam este ponto', 'custom' => 'Indicar uma fonte diferente para o ponto'], 'when' => $active],
'location_point_source_reference' => ['step' => 5, 'pt' => 'Referência da fonte específica do ponto', 'en' => 'Point-specific source reference', 'type' => 'textarea', 'max' => 2000, 'when' => [...$active, 'location_point_source_mode' => ['custom']]],
'location_point_source_date' => ['step' => 5, 'pt' => 'Data dessa fonte (deixe vazio se desconhecida)', 'en' => 'Source date (leave blank if unknown)', 'type' => 'date', 'max' => 10, 'when' => [...$active, 'location_point_source_mode' => ['custom']]],
'location_point_share_source' => ['step' => 5, 'pt' => 'Divulgar também a referência da fonte do ponto?', 'en' => 'Also disclose the point source reference?', 'type' => 'select', 'max' => 3, 'options' => ['yes' => 'Sim — referência sem contactos ou informação reservada, sujeita à publicação aprovada', 'no' => 'Não — manter a referência apenas no processo reservado'], 'when' => $active],
];
}
/** @param array<string,mixed> $payload
* @return array<string, list<string|\Closure|In>> */
public static function rules(array $payload = []): array
{
$rules = [];
foreach (self::fields() as $key => $field) {
@@ -99,6 +119,23 @@ final class BusinessFormSchema
$rules['payload.investment_total'][] = 'regex:/^\d{1,15}(\.\d{1,4})?$/';
$rules['payload.amount_sought'][] = 'regex:/^\d{1,15}(\.\d{1,4})?$/';
$rules['payload.source_date'][] = 'date_format:Y-m-d';
foreach (['location_latitude', 'location_longitude'] as $coordinate) {
// Numeric rules make Laravel interpret max:16 as a numeric ceiling, not a text limit.
$rules['payload.'.$coordinate] = ['nullable', 'string', function ($attribute, $value, $fail) {
if (strlen((string) $value) > 16) {
$fail('Indique a coordenada com até 16 caracteres.');
}
}];
}
if (app(BusinessPointPublication::class)->applicable($payload)) {
if (($payload['location_point_source_mode'] ?? null) === 'custom') {
$rules['payload.location_point_source_date'][] = 'date_format:Y-m-d';
}
$rules['payload.location_latitude'][] = 'numeric';
$rules['payload.location_latitude'][] = 'between:-90,90';
$rules['payload.location_longitude'][] = 'numeric';
$rules['payload.location_longitude'][] = 'between:-180,180';
}
$rules['payload.official_source_url'][] = 'url';
// The field limit is a text length, not an 8% ceiling.
$rules['payload.equity_percentage'] = ['nullable', 'numeric', 'between:0,100',
+125
View File
@@ -0,0 +1,125 @@
<?php
namespace App\Services;
use App\Models\ExistingBusiness;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/** Optional supplement of the approved listing; never grants publication authority. */
final class BusinessPointPublication
{
public function available(): bool
{
return Schema::hasColumn('existing_business_locations', 'public_source_reference');
}
/** @param array<string,mixed> $payload */
public function applicable(array $payload): bool
{
return ($payload['location_point_enabled'] ?? null) === 'yes'
&& in_array($payload['geography_scope'] ?? null, ['specific', 'multiple'], true)
&& ($payload['disclosure_precision'] ?? null) === 'exact'
&& ($payload['location_unknown'] ?? null) === 'no';
}
/** @param array<string,mixed> $payload
* @return list<array<string,mixed>> */
public function paths(array $payload): array
{
if (($payload['geography_scope'] ?? null) === 'specific') {
return [array_intersect_key($payload, InvestmentGeography::FIELDS)];
}
if (($payload['geography_scope'] ?? null) !== 'multiple') {
return [];
}
$rows = json_decode((string) ($payload['locations_json'] ?? ''), true);
return is_array($rows) && array_is_list($rows) ? array_values(array_filter($rows, 'is_array')) : [];
}
/** @param array<string,mixed> $path */
public function pathKey(array $path): string
{
return implode('|', array_map(fn ($field) => (string) ($path[$field] ?? ''), array_keys(InvestmentGeography::FIELDS)));
}
/** @param array<string,mixed> $payload
* @return array<string,mixed>|null */
public function candidate(array $payload): ?array
{
if (! $this->applicable($payload)) {
return null;
}
abort_unless($this->available(), 409, 'O suplemento de localização ainda não está instalado neste destino. O ponto não será publicado.');
$rules = [
'location_unknown' => ['required', 'in:no'],
'location_latitude' => ['required', 'numeric', 'between:-90,90'],
'location_longitude' => ['required', 'numeric', 'between:-180,180'],
'location_point_source_mode' => ['required', 'in:listing,custom'],
'location_point_share_source' => ['nullable', 'in:yes,no'],
'location_point_source_reference' => ['required_if:location_point_source_mode,custom', 'nullable', 'string', 'max:2000'],
'location_point_source_date' => ($payload['location_point_source_mode'] ?? null) === 'custom' ? ['nullable', 'date_format:Y-m-d'] : ['nullable'],
'location_point_territory' => ['required_if:geography_scope,multiple', 'nullable', 'string', 'max:150'],
];
Validator::make($payload, $rules)->validate();
$paths = $this->paths($payload);
$path = ($payload['geography_scope'] ?? null) === 'specific' ? ($paths[0] ?? null)
: collect($paths)->first(fn ($row) => $this->pathKey($row) === $payload['location_point_territory']);
if (! $path || empty($path['province_id'])) {
throw ValidationException::withMessages(['location_point_territory' => 'Seleccione uma das localizações já declaradas para o ponto primário. Não é atribuída uma localização por ordem ou por nome.']);
}
$nodes = app(InvestmentGeography::class)->selection($path);
$listingSource = $payload['location_point_source_mode'] === 'listing';
$source = $listingSource ? ($payload['source_reference'] ?? null) : ($payload['location_point_source_reference'] ?? null);
$date = $listingSource ? ($payload['source_date'] ?? null) : ($payload['location_point_source_date'] ?? null);
Validator::make(['location_point_source_reference' => $source, 'location_point_source_date' => $date], [
'location_point_source_reference' => ['required', 'string', 'max:2000'], 'location_point_source_date' => ['nullable', 'date_format:Y-m-d'],
])->validate();
return ['path' => array_combine(array_keys(InvestmentGeography::FIELDS), array_map(fn ($field) => ($path[$field] ?? null) ?: null, array_keys(InvestmentGeography::FIELDS))),
'reference_snapshot' => array_map(fn ($node) => (array) $node, $nodes),
'latitude' => (float) $payload['location_latitude'], 'longitude' => (float) $payload['location_longitude'],
'source_reference' => $source, 'source_date' => $date ?: null,
'public_source_reference' => ($payload['location_point_share_source'] ?? null) === 'yes' ? $source : null];
}
/** Called only within the existing authorized, locked publication transaction.
* @return array<string,mixed> */
public function publish(ExistingBusiness $business): array
{
$point = $this->candidate($business->payload);
if ($point === null) {
return [];
}
$previous = DB::table('existing_business_locations')->where('existing_business_id', $business->id)->where('is_primary', true)
->select('*')->selectRaw('ST_AsEWKT(geom) AS geometry_snapshot')->first();
$geometry = DB::selectOne('SELECT ST_AsEWKT(ST_SetSRID(ST_MakePoint(?::float8, ?::float8),4326)) AS value', [$point['longitude'], $point['latitude']])->value;
$values = [...$point['path'], 'precision_level' => 'exact', 'is_public' => true,
'geom' => $geometry,
'public_source_reference' => $point['public_source_reference'], 'source_date' => $point['source_date'],
'source_system' => 'portal-business-form', 'source_payload_version' => $business->version_no + 1, 'updated_at' => now()];
if ($previous) {
DB::table('existing_business_locations')->where('id', $previous->id)->update($values);
} else {
DB::table('existing_business_locations')->insert([...$values, 'id' => (string) Str::uuid(), 'existing_business_id' => $business->id, 'is_primary' => true, 'created_at' => now()]);
}
return ['point_publication' => ['previous' => $previous ? (array) $previous : null, 'used' => $point,
'business_version' => $business->version_no + 1, 'source' => 'explicit_listing_point_supplement']];
}
/** Private view, only after the listing's normal view authorization.
* @return array<string,mixed>|null */
public function registered(ExistingBusiness $business): ?array
{
$row = DB::table('existing_business_locations')->where('existing_business_id', $business->id)->where('is_primary', true)
->select('id', 'precision_level', 'is_public', 'province_id', 'district_id', 'admin_post_id', 'locality_id')
->selectRaw("geom IS NOT NULL AS has_geometry, CASE WHEN GeometryType(geom)='POINT' THEN ST_Y(geom) END AS latitude, CASE WHEN GeometryType(geom)='POINT' THEN ST_X(geom) END AS longitude")->first();
return $row ? (array) $row : null;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Services;
use App\Models\ExistingBusiness;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class BusinessProximitySearch
{
/** @return array<string,mixed> */
public function origin(string $id): array
{
$row = DB::query()->fromSub(app(PublicBusinessLocations::class)->points(), 'origin_point')
->joinSub(ExistingBusiness::published()->select('id', 'public_title'), 'origin_business', 'origin_business.id', '=', 'origin_point.existing_business_id')
->where('origin_point.existing_business_id', $id)->whereNotNull('origin_point.point')
->select('origin_point.location_recorded_at', 'origin_point.location_source_reference', 'origin_point.location_source_date', 'origin_business.public_title')->selectRaw('ST_AsEWKT(origin_point.point) AS ewkt')->first();
if (! $row) {
throw ValidationException::withMessages(['near_business' => 'A ficha de referência não tem um ponto primário público exacto utilizável. Escolha outra referência ou pesquise por área.']);
}
return ['id' => $id, 'title' => $row->public_title, 'ewkt' => $row->ewkt, 'source_reference' => $row->location_source_reference, 'source_date' => $row->location_source_date, 'location_recorded_at' => $row->location_recorded_at ? CarbonImmutable::parse($row->location_recorded_at)->toIso8601String() : null];
}
public function available(string $id): bool
{
return DB::query()->fromSub(app(PublicBusinessLocations::class)->points(), 'point')
->whereIn('existing_business_id', ExistingBusiness::published()->select('id'))
->where('existing_business_id', $id)->whereNotNull('point')->exists();
}
/** @param Builder<ExistingBusiness> $query
* @param array<string,mixed> $origin
* @return array<string,mixed> */
public function constrain(Builder $query, array $origin, float $radius): array
{
$query->leftJoinSub(app(PublicBusinessLocations::class)->points(), 'public_point', 'public_point.existing_business_id', '=', 'existing_businesses.id');
$withPoint = (clone $query)->whereNotNull('public_point.point')->count();
$withoutPoint = (clone $query)->whereNull('public_point.point')->count();
$query->whereNotNull('public_point.point')->whereRaw('ST_DWithin(public_point.point::geography, ST_GeomFromEWKT(?)::geography, ?)', [$origin['ewkt'], $radius * 1000])
->addSelect('public_point.location_recorded_at')
->selectRaw('ST_Distance(public_point.point::geography, ST_GeomFromEWKT(?)::geography) / 1000.0 AS distance_km', [$origin['ewkt']])
->orderBy('distance_km')->orderBy('existing_businesses.id');
return ['origin_id' => $origin['id'], 'origin_title' => $origin['title'], 'radius_km' => $radius, 'origin_recorded_at' => $origin['location_recorded_at'],
'origin_source_reference' => $origin['source_reference'], 'origin_source_date' => $origin['source_date'],
'with_public_point' => $withPoint, 'without_public_point' => $withoutPoint,
'source' => 'Localizações primárias públicas do cadastro de negócios do portal',
'method' => 'Distância geodésica em linha recta entre pontos publicados; não é distância por estrada nem validação da localização real.'];
}
}
+208
View File
@@ -0,0 +1,208 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Informative comparison, stored in the existing encrypted dossier and event history. */
final class CompanyComparison
{
public const EVENT = 'company_comparison_saved';
public function __construct(private readonly VirtualDesk $desk) {}
/** @return array<string,mixed> */
public function definition(): array
{
return config('company-comparison');
}
/** @param array<array-key,mixed> $questions
* @return list<string> */
private function questionKeys(array $questions): array
{
$keys = [];
foreach (array_keys($questions) as $key) {
if (! is_string($key)) {
throw new \LogicException('Comparison question identifiers must be strings.');
}
$keys[] = $key;
}
return $keys;
}
/** @param array<string,mixed> $input
* @return array<string,string> */
public function answers(array $input): array
{
return $this->normalize($input, $this->definition()['questions']);
}
/** @param array<string,mixed> $input
* @param array<array-key,mixed> $questions
* @return array<string,string> */
private function normalize(array $input, array $questions): array
{
$keys = $this->questionKeys($questions);
$rules = ['answers' => ['sometimes', 'array:'.implode(',', $keys)]];
foreach ($keys as $key) {
$rules['answers.'.$key] = ['nullable', 'string', Rule::in(array_keys($questions[$key]['options']))];
}
$values = Validator::make($input, $rules)->validate()['answers'] ?? [];
$answers = [];
foreach ($keys as $key) {
$answers[$key] = is_string($values[$key] ?? null) ? $values[$key] : 'unknown';
}
return $answers;
}
/** @param array<string,string> $answers
* @return list<string> */
public function active(array $answers): array
{
return in_array($answers['purpose'], ['representation', 'cooperation'], true) ? ['purpose', 'regulated'] : $this->questionKeys($this->definition()['questions']);
}
/** @param array<string,string> $answers
* @return array<string,mixed> */
public function evaluate(array $answers): array
{
$definition = $this->definition();
$active = $this->active($answers);
$pending = array_values(array_filter($active, fn ($key) => $key !== 'preferred' && $answers[$key] === 'unknown'));
$tasks = ['Definir contribuições, gestão, direitos e obrigações no acto a preparar.', 'Conferir os actos e o registo aplicáveis à alternativa escolhida.'];
if (in_array('capital', $active, true)) {
$tasks[] = $answers['capital'] === 'defined' ? 'Conferir os valores e o modo de realização das contribuições definidas.' : 'Definir o capital e as contribuições ainda em falta.';
if (in_array($answers['management'], ['participants', 'both'], true)) {
$tasks[] = 'Definir poderes de gestão e regras de deliberação dos participantes.';
}
if (in_array($answers['management'], ['appointed', 'both'], true)) {
$tasks[] = 'Definir a nomeação, o mandato e a prestação de contas da administração.';
}
if ($answers['responsibility'] !== 'unknown') {
$tasks[] = 'Conferir no registo e no contrato o regime de responsabilidade e as excepções aplicáveis.';
}
if ($answers['listing'] === 'yes') {
$tasks[] = 'Conferir os requisitos específicos da emissão e do mercado pretendido.';
}
}
if ($answers['regulated'] !== 'no') {
$tasks[] = 'Conferir as regras e habilitações específicas da actividade.';
}
$route = match ($answers['purpose']) {
'representation' => 'Compare a representação da sociedade existente; não é uma nova forma societária (artigo 69).',
'cooperation' => 'Prepare o acordo de cooperação; consórcio não é apresentado como uma forma societária autónoma.',
default => 'Compare alternativas para discutir a constituição; não existe uma forma universalmente preferível.',
};
$forms = [];
if (! in_array($answers['purpose'], ['representation', 'cooperation'], true)) {
foreach ($definition['forms'] as $code => $form) {
$constraints = [];
$notes = [];
if ($code === 'individual' && $answers['participants'] === 'several') {
$constraints[] = 'A titularidade individual não corresponde a vários titulares (artigo 3).';
}
if ($code === 'collective' && $answers['participants'] === 'one') {
$constraints[] = 'São necessários pelo menos dois sócios (artigo 264).';
}
if ($code === 'simplified' && $answers['listing'] === 'yes') {
$constraints[] = 'As acções desta forma não podem ser negociadas em Bolsa (artigo 444).';
}
if ($answers['participants'] === 'one' && in_array($code, ['quotas', 'anonymous', 'simplified'], true)) {
$notes[] = 'Comparar a modalidade unipessoal (artigo 257).';
}
if (($answers['participation'] === 'shares' && $code === 'quotas') || ($answers['participation'] === 'quotas' && in_array($code, ['anonymous', 'simplified'], true))) {
$notes[] = 'A estrutura de participação difere da preferência indicada.';
}
if ($answers['participation'] === 'work') {
$notes[] = $code === 'collective' ? 'Conferir a contribuição em trabalho (artigos 264–266).' : 'Conferir como realizar as contribuições pretendidas.';
}
$forms[] = ['code' => $code, ...$form, 'constraints' => $constraints, 'notes' => $notes,
'selected' => $answers['preferred'] === $code, 'source_href' => $definition['source']['url'].'#page='.$form['page']];
}
}
return ['engine_version' => $definition['version'], 'source' => $definition['source'], 'questions' => $definition['questions'], 'active' => $active,
'pending' => $pending, 'route' => $route, 'forms' => $forms, 'preparation' => $tasks,
'scope' => 'Comparação informativa da edição indicada; a actividade, o contrato e as regras especiais continuam a exigir conferência própria.'];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function view(User $actor, string $id, array $input): array
{
$case = $this->desk->case($actor, $id);
$saved = $this->desk->decode($case->payload)['company_comparison'] ?? null;
$v = Validator::make($input, ['revision' => 'nullable|uuid', 'history_before' => 'nullable|integer|min:1'])->validate();
if (! empty($v['revision'])) {
if (array_key_exists('answers', $input)) {
throw ValidationException::withMessages(['answers' => 'Abra a preparação actual para comparar outras respostas; a revisão histórica conserva os dados guardados.']);
}
$row = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->where('id', $v['revision'])->first();
abort_unless($row !== null, 404);
$saved = $this->desk->decode($row->payload)['snapshot'];
}
$answers = array_key_exists('answers', $input) ? $this->answers($input) : ($saved['answers'] ?? $this->answers([]));
$result = array_key_exists('answers', $input) || ! $saved ? $this->evaluate($answers) : $saved['result'];
$events = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)
->when(! empty($v['history_before']), fn ($q) => $q->where('event_order', '<', $v['history_before']))
->orderByDesc('event_order')->limit(26)->get(['id', 'payload', 'event_order']);
$history = $events->take(25)->map(function ($row) {
$snapshot = $this->desk->decode($row->payload)['snapshot'];
return ['id' => $row->id, 'revision' => $snapshot['revision'], 'at' => $snapshot['at'], 'engine_version' => $snapshot['result']['engine_version']];
})->all();
return ['case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version],
'canWrite' => ! $case->closed_at && $this->desk->owns($actor, $case), 'answers' => $answers, 'result' => $result,
'saved' => $saved, 'history' => $history, 'historyNext' => $events->count() > 25 ? $events[24]->event_order : null,
'historical' => ! empty($v['revision']), 'workingCopy' => array_key_exists('answers', $input)];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function save(User $actor, string $id, array $input): array
{
$this->desk->case($actor, $id, true);
$v = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid'])->validate();
$v['version'] = (int) $v['version'];
return DB::transaction(function () use ($actor, $id, $v, $input) {
$case = $this->desk->case($actor, $id, true, true);
foreach (DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->orderByDesc('event_order')->cursor() as $event) {
$data = $this->desk->decode($event->payload);
if ($data['operation_key'] === $v['operation_key']) {
$answers = $this->normalize($input, $data['snapshot']['result']['questions']);
$hash = hash('sha256', json_encode([$v['version'], $answers], JSON_THROW_ON_ERROR));
abort_unless(hash_equals($data['request_hash'], $hash), 409, 'Esta operação já guardou respostas diferentes. Reabra a comparação.');
return ['saved' => true, 'replayed' => true, 'revision' => $data['snapshot']['revision']];
}
}
$answers = $this->answers($input);
$hash = hash('sha256', json_encode([$v['version'], $answers], JSON_THROW_ON_ERROR));
abort_if($case->closed_at || (int) $case->version !== (int) $v['version'], 409, 'O dossier mudou ou foi fechado. Conserve as respostas e recupere a versão actual antes de guardar.');
$payload = $this->desk->decode($case->payload);
$result = $this->evaluate($answers);
$previous = $payload['company_comparison'] ?? null;
if ($previous !== null && $previous['answers'] === $answers && $previous['result'] === $result) {
return ['saved' => true, 'replayed' => false, 'unchanged' => true, 'revision' => $previous['revision']];
}
$snapshot = ['revision' => (int) ($payload['company_comparison']['revision'] ?? 0) + 1, 'answers' => $answers,
'result' => $result, 'at' => now()->toIso8601String(), 'actor_id' => $actor->id];
$payload['company_comparison'] = $snapshot;
$case->version++;
DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'version' => $case->version, 'updated_at' => now()]);
$this->desk->event($case, $actor, self::EVENT, ['operation_key' => $v['operation_key'], 'request_hash' => $hash, 'snapshot' => $snapshot]);
return ['saved' => true, 'replayed' => false, 'revision' => $snapshot['revision']];
});
}
}
+285
View File
@@ -0,0 +1,285 @@
<?php
namespace App\Services;
use App\Models\User;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
/** Factual preparation only. Actual contractual exemplars have not been supplied. */
final class CompanyPreparation
{
public const EVENT = 'company_preparation_saved';
public const VERSION = 'company-preparation-1';
public function __construct(private readonly VirtualDesk $desk) {}
/** @return array<string,mixed> */
public function definition(): array
{
return ['version' => self::VERSION, 'forms' => config('company-comparison.forms'),
'roles' => ['subscriber' => 'Titular, sócio ou accionista', 'administrator' => 'Administrador ou gerente', 'representative' => 'Representante', 'beneficial_owner' => 'Beneficiário efectivo declarado'],
'source' => config('company-comparison.source'),
'template_status' => 'source_pending', 'template_notice' => 'Os sete exemplares referidos no complemento ainda não foram fornecidos. Esta ficha prepara dados para revisão; não é uma minuta contratual, assinatura, registo ou licença.'];
}
/** Reuse only the already-authorized, frozen dossier values, never a fresh master lookup.
* @return array<string,array<string,mixed>> */
private function sources(\stdClass $case): array
{
$payload = $this->desk->decode($case->payload);
$sources = [];
if (! empty($payload['name'])) {
$sources['case_proponent'] = ['name' => $payload['name'], 'kind' => 'person', 'reference' => $case->id,
'version' => $payload['reuse_consent']['date'] ?? 'creation', 'origin' => 'Proponente conservado na criação do dossier'];
}
if ($case->company_id && ! empty($payload['company']['name'])) {
$sources['case_company'] = ['name' => $payload['company']['name'], 'kind' => 'entity', 'reference' => $case->company_id,
'version' => $payload['company_version'] ?? null, 'origin' => 'Declaração de empresa conservada no dossier'];
}
return $sources;
}
/** @param array<string,mixed> $input
* @param array<string,mixed>|null $definition
* @return array<string,mixed> */
public function facts(array $input, ?array $definition = null): array
{
$definition ??= $this->definition();
$text = ['nullable', 'string', 'max:2000'];
$number = ['nullable', 'string', 'regex:/^\d{1,18}(?:[.,]\d{1,6})?$/D'];
$rules = ['facts' => ['present', 'array:form,proposed_name,seat,activities,regulated,currency,capital,nominal_value,management,binding,notes,parties'],
'facts.form' => ['nullable', Rule::in(array_keys($definition['forms']))],
'facts.proposed_name' => ['nullable', 'string', 'max:250'], 'facts.seat' => $text, 'facts.activities' => $text,
'facts.regulated' => ['nullable', Rule::in(['unknown', 'yes', 'no'])],
'facts.currency' => ['nullable', 'string', 'regex:/^[A-Z]{3}$/D'], 'facts.capital' => $number, 'facts.nominal_value' => $number,
'facts.management' => $text, 'facts.binding' => $text, 'facts.notes' => $text,
'facts.parties' => ['sometimes', 'array', 'list', 'max:100'],
'facts.parties.*' => ['array:id,name,kind,source,roles,represented_id,cash,in_kind,paid,share_count,work,commitment'],
'facts.parties.*.id' => ['required', 'uuid', 'distinct'], 'facts.parties.*.name' => ['nullable', 'string', 'max:250'],
'facts.parties.*.kind' => ['required', Rule::in(['person', 'entity'])],
'facts.parties.*.source' => ['nullable', Rule::in(['case_proponent', 'case_company'])],
'facts.parties.*.roles' => ['present', 'array', 'list', 'max:4'],
'facts.parties.*.roles.*' => ['string', Rule::in(array_keys($definition['roles']))],
'facts.parties.*.represented_id' => ['nullable', 'uuid'],
'facts.parties.*.cash' => $number, 'facts.parties.*.in_kind' => $number, 'facts.parties.*.paid' => $number,
'facts.parties.*.share_count' => ['nullable', 'string', 'regex:/^\d{1,18}$/D'],
'facts.parties.*.work' => $text, 'facts.parties.*.commitment' => $text];
$v = Validator::make($input, $rules)->validate()['facts'];
$facts = [];
foreach (['form', 'proposed_name', 'seat', 'activities', 'currency', 'management', 'binding', 'notes'] as $key) {
$facts[$key] = $v[$key] ?? null;
}
$facts['regulated'] = $v['regulated'] ?? 'unknown';
foreach (['capital', 'nominal_value'] as $key) {
$facts[$key] = $this->decimal($v[$key] ?? null);
}
$facts['parties'] = [];
foreach ($v['parties'] ?? [] as $party) {
$p = [];
foreach (['id', 'name', 'kind', 'source', 'represented_id', 'work', 'commitment'] as $key) {
$p[$key] = $party[$key] ?? null;
}
$p['roles'] = array_values(array_unique($party['roles']));
sort($p['roles']);
foreach (['cash', 'in_kind', 'paid', 'share_count'] as $key) {
$p[$key] = $this->decimal($party[$key] ?? null);
}
$facts['parties'][] = $p;
}
return $facts;
}
private function decimal(?string $value): ?string
{
return $value === null ? null : (string) BigDecimal::of(str_replace(',', '.', $value))->strippedOfTrailingZeros();
}
/** @param array<string,mixed> $facts
* @param array<string,array<string,mixed>> $sources
* @param array<string,mixed>|null $previous
* @return array<string,mixed> */
private function resolve(array $facts, array $sources, ?array $previous): array
{
foreach ($facts['parties'] as &$party) {
if ($party['source'] !== null) {
$old = null;
foreach ($previous['facts']['parties'] ?? [] as $candidate) {
if ($candidate['id'] === $party['id'] && $candidate['source'] === $party['source']) {
$old = $candidate;
break;
}
}
$source = $old['source_snapshot'] ?? $sources[$party['source']] ?? null;
abort_unless($source !== null, 422, 'A origem seleccionada não existe neste dossier.');
$party['name'] = $source['name'];
$party['kind'] = $source['kind'];
$party['source_snapshot'] = $source;
}
}
return $facts;
}
/** @param array<string,mixed> $facts
* @return array<string,mixed> */
public function review(array $facts): array
{
$issues = [];
foreach (['form' => 'Definir a forma a preparar.', 'proposed_name' => 'Indicar a denominação proposta.', 'seat' => 'Indicar a sede proposta.', 'activities' => 'Descrever as actividades.', 'management' => 'Descrever a administração pretendida.', 'binding' => 'Indicar a forma de obrigar pretendida.'] as $key => $message) {
if (! $facts[$key]) {
$issues[] = $message;
}
}
$subscribers = array_values(array_filter($facts['parties'], fn ($p) => in_array('subscriber', $p['roles'], true)));
if ($subscribers === []) {
$issues[] = 'Identificar os titulares, sócios ou accionistas.';
}
if ($facts['form'] === 'collective' && count($subscribers) < 2) {
$issues[] = 'A forma colectiva exige pelo menos dois sócios (artigo 264).';
}
if ($facts['form'] === 'individual' && count($subscribers) > 1) {
$issues[] = 'A titularidade individual não corresponde a vários titulares.';
}
$corporate = $facts['form'] !== null && $facts['form'] !== 'individual';
$shares = in_array($facts['form'], ['anonymous', 'simplified'], true);
$total = BigDecimal::zero();
$paid = BigDecimal::zero();
$units = BigDecimal::zero();
$complete = $subscribers !== [];
$paymentsComplete = $complete;
$unitsComplete = $complete;
$ids = array_column($facts['parties'], 'id');
$usedSources = [];
foreach ($facts['parties'] as $party) {
if ($party['source'] && in_array($party['source'], $usedSources, true)) {
$issues[] = 'A mesma origem foi seleccionada em mais de uma linha; conferir duplicação sem fundir pessoas por nome.';
}
$usedSources[] = $party['source'];
if (! $party['name'] || $party['roles'] === []) {
$issues[] = 'Completar o nome e os papéis de cada interveniente.';
}
if (in_array('representative', $party['roles'], true) && (! $party['represented_id'] || $party['represented_id'] === $party['id'] || ! in_array($party['represented_id'], $ids, true))) {
$issues[] = 'Identificar quem cada representante representa, numa linha distinta.';
}
if (! in_array('subscriber', $party['roles'], true) || ! $corporate) {
continue;
}
$complete = $complete && $party['cash'] !== null && $party['in_kind'] !== null;
$subscription = BigDecimal::of($party['cash'] ?? '0')->plus($party['in_kind'] ?? '0');
$total = $total->plus($subscription);
$paymentsComplete = $paymentsComplete && $party['paid'] !== null;
$paid = $paid->plus($party['paid'] ?? '0');
if ($party['paid'] !== null && $party['cash'] !== null && $party['in_kind'] !== null && BigDecimal::of($party['paid'])->compareTo($subscription) > 0) {
$issues[] = 'Uma realização declarada excede a subscrição desse participante.';
}
$unitsComplete = $unitsComplete && $party['share_count'] !== null;
$units = $units->plus($party['share_count'] ?? '0');
if ($shares && $party['share_count'] !== null && $facts['nominal_value'] !== null && $party['cash'] !== null && $party['in_kind'] !== null && BigDecimal::of($party['share_count'])->multipliedBy($facts['nominal_value'])->compareTo($subscription) !== 0) {
$issues[] = 'O valor nominal das acções de um participante difere da sua subscrição.';
}
if ($party['work'] && $facts['form'] !== 'collective') {
$issues[] = 'Conferir a admissibilidade da contribuição em trabalho para a forma escolhida.';
}
}
$totals = null;
if ($corporate) {
if (! $facts['currency'] || $facts['capital'] === null || ! $complete) {
$issues[] = 'Completar moeda, capital e entradas em dinheiro/espécie; valores desconhecidos não são zero.';
}
if ($complete && $facts['capital'] !== null && $total->compareTo($facts['capital']) !== 0) {
$issues[] = 'A soma das subscrições difere do capital declarado.';
}
if (! $paymentsComplete) {
$issues[] = 'Completar a realização declarada de cada subscrição; não equivale a prova de pagamento.';
}
if ($shares && ($facts['nominal_value'] === null || ! $unitsComplete)) {
$issues[] = 'Completar o número de acções e o valor nominal para reconciliar as participações.';
}
$totals = ['currency' => $facts['currency'], 'subscribed' => $complete && $facts['currency'] ? (string) $total : null,
'paid' => $paymentsComplete && $facts['currency'] ? (string) $paid : null,
'unpaid' => $complete && $paymentsComplete && $facts['currency'] ? (string) $total->minus($paid) : null,
'shares_nominal' => $shares && $unitsComplete && $facts['nominal_value'] !== null && $facts['currency'] ? (string) $units->multipliedBy($facts['nominal_value']) : null];
}
if ($facts['regulated'] !== 'no') {
$issues[] = 'Conferir os requisitos sectoriais ou profissionais da actividade; o objecto não concede licença.';
}
return ['issues' => array_values(array_unique($issues)), 'totals' => $totals, 'status' => 'preparation',
'note' => $facts['form'] === 'collective' ? 'As contribuições em trabalho são conservadas separadamente e não integram o capital (artigo 266).' : 'Valores declarados para preparação; não comprovam entradas, poderes ou registo.'];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function view(User $actor, string $id, array $input): array
{
$case = $this->desk->case($actor, $id);
$payload = $this->desk->decode($case->payload);
$v = Validator::make($input, ['revision' => 'nullable|uuid', 'history_before' => 'nullable|integer|min:1'])->validate();
$saved = $payload['company_preparation'] ?? null;
if (! empty($v['revision'])) {
$event = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->where('id', $v['revision'])->first();
abort_unless($event !== null, 404);
$saved = $this->desk->decode($event->payload)['snapshot'];
}
$facts = $saved['facts'] ?? $this->facts(['facts' => []]);
$events = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)
->when(! empty($v['history_before']), fn ($q) => $q->where('event_order', '<', $v['history_before']))
->orderByDesc('event_order')->limit(26)->get(['id', 'payload', 'event_order']);
return ['case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version],
'canWrite' => ! $case->closed_at && $this->desk->owns($actor, $case), 'historical' => ! empty($v['revision']),
'definition' => $saved['definition'] ?? $this->definition(), 'facts' => $facts, 'review' => $saved['review'] ?? $this->review($facts), 'saved' => $saved,
'sources' => $this->sources($case), 'history' => $events->take(25)->map(function ($e) {
$s = $this->desk->decode($e->payload)['snapshot'];
return ['id' => $e->id, 'revision' => $s['revision'], 'at' => $s['at']];
})->all(), 'historyNext' => $events->count() > 25 ? $events[24]->event_order : null];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function save(User $actor, string $id, array $input): array
{
$this->desk->case($actor, $id, true);
$v = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid'])->validate();
return DB::transaction(function () use ($actor, $id, $v, $input) {
$case = $this->desk->case($actor, $id, true, true);
foreach (DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->orderByDesc('event_order')->cursor() as $event) {
$data = $this->desk->decode($event->payload);
if ($data['operation_key'] === $v['operation_key']) {
$facts = $this->facts($input, $data['snapshot']['definition']);
$hash = hash('sha256', json_encode([(int) $v['version'], $facts], JSON_THROW_ON_ERROR));
abort_unless(hash_equals($data['request_hash'], $hash), 409, 'A operação já guardou outros dados.');
return ['saved' => true, 'replayed' => true, 'revision' => $data['snapshot']['revision']];
}
}
$facts = $this->facts($input);
$hash = hash('sha256', json_encode([(int) $v['version'], $facts], JSON_THROW_ON_ERROR));
abort_if($case->closed_at || (int) $case->version !== (int) $v['version'], 409, 'O dossier mudou ou foi fechado. Os dados continuam no formulário; recupere a versão antes de guardar.');
$payload = $this->desk->decode($case->payload);
$previous = $payload['company_preparation'] ?? null;
$resolved = $this->resolve($facts, $this->sources($case), $previous);
$definition = $this->definition();
$review = $this->review($resolved);
if ($previous && $previous['facts'] === $resolved && $previous['definition'] === $definition && $previous['review'] === $review) {
return ['saved' => true, 'unchanged' => true, 'revision' => $previous['revision']];
}
$snapshot = ['revision' => (int) ($previous['revision'] ?? 0) + 1, 'at' => now()->toIso8601String(), 'actor_id' => $actor->id,
'facts' => $resolved, 'definition' => $definition, 'review' => $review];
$payload['company_preparation'] = $snapshot;
$case->version++;
DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'version' => $case->version, 'updated_at' => now()]);
$this->desk->event($case, $actor, self::EVENT, ['operation_key' => $v['operation_key'], 'request_hash' => $hash, 'snapshot' => $snapshot]);
return ['saved' => true, 'replayed' => false, 'revision' => $snapshot['revision']];
});
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Services;
use App\Models\CrmTask;
use App\Models\Lead;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\ValidationException;
final class CrmNextAction
{
/** @return Builder<CrmTask> */
public function future(Lead $lead, ?string $exceptTask = null): Builder
{
return CrmTask::availableForWork()->where('tasks.lead_id', $lead->id)->where('tasks.due_at', '>', now())
->when($exceptTask !== null, fn ($query) => $query->where('tasks.id', '!=', $exceptTask));
}
/** Both write paths lock the dossier (when linked), lead, then task before checking. */
public function assertMayFinish(?Lead $lead, CrmTask $task, string $status): void
{
if ($lead !== null && ! $lead->trashed() && in_array($lead->status, ['new', 'qualified'], true)
&& in_array($status, ['done', 'cancelled'], true) && ! $this->future($lead, $task->id)->exists()) {
throw ValidationException::withMessages(['status' => 'Crie a próxima acção com prazo futuro antes de encerrar a última tarefa de um acompanhamento activo. Tarefas num dossier encerrado não estão disponíveis.']);
}
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Services;
use App\Models\CrmTask;
use App\Models\Lead;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
final class CrmTaskCreation
{
private const SOURCE = 'portal-crm-task-creation';
/** @param array<string,mixed> $input
* @param array<int,int> $assignees
* @return array{task_id:string,version:int,replayed:bool}
*/
public function create(User $actor, Lead $lead, array $input, array $assignees): array
{
abort_unless(Gate::forUser($actor)->allows('manage-portal'), 403);
$data = Validator::make($input, ['client_key' => 'nullable|uuid', 'title' => 'required|string|max:200', 'due_at' => 'required|date', 'assignee_user_id' => 'required|integer'])->validate();
$key = is_string($data['client_key'] ?? null) ? strtolower($data['client_key']) : null;
$values = ['title' => trim($data['title']), 'due_at' => CarbonImmutable::parse($data['due_at'])->utc()->format('Y-m-d\TH:i:s.u\Z'), 'assignee_user_id' => (int) $data['assignee_user_id']];
$hash = hash('sha256', json_encode($values, JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $lead, $values, $key, $hash, $assignees) {
$lead = Lead::lockForUpdate()->findOrFail($lead->id);
if ($key !== null) {
$event = $this->query($actor, $lead)->whereRaw("after_data->'task_creation'->>'key' = ?", [$key])->selectRaw("entity_id,after_data->'task_creation'->>'hash' AS hash,after_data->'task_creation'->'version' AS version")->first();
if ($event) {
abort_unless($event->hash === $hash, 409, 'Esta tentativa já foi usada com outros dados. Compare as tarefas antes de continuar.');
$version = is_string($event->version) ? json_decode($event->version, true, 512, JSON_THROW_ON_ERROR) : $event->version;
abort_unless(is_string($event->entity_id) && Str::isUuid($event->entity_id) && is_int($version) && $version > 0, 409, 'A criação anterior não foi identificada com segurança.');
return ['task_id' => $event->entity_id, 'version' => $version, 'replayed' => true];
}
}
Validator::make($values, ['title' => 'required|string|max:200', 'due_at' => 'required|date|after:now', 'assignee_user_id' => ['required', 'integer', Rule::in($assignees)]])->validate();
$task = new CrmTask;
PortalAudit::save($task, [...$values, 'lead_id' => $lead->id, 'status' => 'open'], $actor->id);
if ($key !== null) {
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'actor_user_id' => $actor->id, 'entity_type' => 'tasks', 'entity_id' => $task->id, 'action' => 'created', 'data_classification' => 'internal',
'after_data' => json_encode(['task_creation' => ['lead_id' => $lead->id, 'key' => $key, 'hash' => $hash, 'version' => (int) $task->getAttribute('version_no')]], JSON_THROW_ON_ERROR)]);
}
return ['task_id' => $task->id, 'version' => (int) $task->getAttribute('version_no'), 'replayed' => false];
});
}
/** @return array{lead_id:string,actor_id:int,commands:list<array{key:string,task_id:string,version:int}>,preparation:array<string,mixed>} */
public function context(User $actor, Lead $lead): array
{
abort_unless(Gate::forUser($actor)->allows('manage-portal'), 403);
$commands = [];
foreach ($this->query($actor, $lead)->orderBy('occurred_at')->selectRaw("entity_id,after_data->'task_creation'->>'key' AS key,after_data->'task_creation'->'version' AS version")->get() as $event) {
$version = is_string($event->version) ? json_decode($event->version, true, 512, JSON_THROW_ON_ERROR) : $event->version;
if (is_string($event->key) && Str::isUuid($event->key) && is_string($event->entity_id) && Str::isUuid($event->entity_id) && is_int($version) && $version > 0) {
$commands[] = ['key' => $event->key, 'task_id' => $event->entity_id, 'version' => $version];
}
}
return ['lead_id' => $lead->id, 'actor_id' => $actor->id, 'commands' => $commands, 'preparation' => app(CrmTaskPreparations::class)->view($actor, $lead)];
}
private function query(User $actor, Lead $lead): Builder
{
return DB::table('audit_events')->where('source_system', self::SOURCE)->where('actor_user_id', $actor->id)->where('entity_type', 'tasks')->where('action', 'created')
->whereRaw("after_data->'task_creation'->>'lead_id' = ?", [$lead->id]);
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace App\Services;
use App\Models\Lead;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
final class CrmTaskPreparations
{
public function installed(): bool
{
return Schema::hasTable('crm_task_preparations');
}
/** @return array<string,mixed> */
public function view(User $actor, Lead $lead): array
{
abort_unless(Gate::forUser($actor)->allows('manage-portal'), 403);
$row = $this->installed() ? DB::table('crm_task_preparations')->where('actor_id', $actor->id)->where('lead_id', $lead->id)->first() : null;
$snapshot = null;
$unavailable = false;
if ($row) {
try {
$decoded = json_decode(Crypt::decryptString($row->snapshot), true);
$validator = Validator::make(['snapshot' => $decoded], $this->rules());
if ($validator->fails()) {
$unavailable = true;
} else {
$snapshot = $this->normalize($validator->validated()['snapshot']);
}
} catch (DecryptException) {
$unavailable = true;
}
}
return ['installed' => $this->installed(), 'version' => (int) ($row->version ?? 0), 'snapshot' => $snapshot, 'operation_key' => $row->operation_key ?? null,
'updated_at' => $row->updated_at ?? null, 'unavailable' => $unavailable];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function save(User $actor, Lead $lead, array $input): array
{
abort_unless(Gate::forUser($actor)->allows('manage-portal'), 403);
abort_unless($this->installed(), 409, 'A recuperação persistente ainda não está instalada. A criação manual continua disponível.');
$data = Validator::make($input, [...$this->rules(), 'version' => 'required|integer|min:0', 'operation_key' => 'required|uuid'])->validate();
$data['snapshot'] = $this->normalize($data['snapshot']);
$key = strtolower($data['operation_key']);
$hash = hash('sha256', json_encode(Arr::sortRecursive(['version' => (int) $data['version'], 'snapshot' => $data['snapshot']]), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $lead, $data, $key, $hash): array {
$lead = Lead::lockForUpdate()->findOrFail($lead->id);
$row = DB::table('crm_task_preparations')->where('actor_id', $actor->id)->where('lead_id', $lead->id)->lockForUpdate()->first();
if ($row && $row->operation_key === $key) {
abort_unless(hash_equals($row->operation_sha256, $hash), 409, 'Esta tentativa de guardar já foi usada com outros dados.');
return app(CrmTaskCreation::class)->context($actor, $lead);
}
abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'A preparação mudou noutro separador. Compare antes de substituir.');
if ($row) {
abort_if($this->view($actor, $lead)['unavailable'], 409, 'A preparação guardada não pôde ser recuperada. Os dados anteriores foram preservados.');
}
$version = (int) ($row->version ?? 0) + 1;
DB::table('crm_task_preparations')->updateOrInsert(['actor_id' => $actor->id, 'lead_id' => $lead->id], [
'version' => $version, 'operation_key' => $key, 'operation_sha256' => $hash,
'snapshot' => Crypt::encryptString(json_encode($data['snapshot'], JSON_THROW_ON_ERROR)),
'created_at' => $row->created_at ?? now(), 'updated_at' => now(),
]);
DB::table('audit_events')->insert(['source_system' => 'portal-crm-task-preparation', 'entity_type' => 'crm_task_preparations', 'entity_id' => $lead->id,
'actor_user_id' => $actor->id, 'action' => 'draft_checkpoint_saved', 'data_classification' => 'restricted', 'after_data' => json_encode(['version' => $version], JSON_THROW_ON_ERROR)]);
return app(CrmTaskCreation::class)->context($actor, $lead);
});
}
/** @param array<string,mixed> $snapshot
* @return array<string,mixed> */
private function normalize(array $snapshot): array
{
foreach (['title', 'due_at', 'assignee_user_id'] as $field) {
$snapshot['generation'][$field] = (int) $snapshot['generation'][$field];
if (is_array($snapshot['pending'])) {
$snapshot['pending']['generation'][$field] = (int) $snapshot['pending']['generation'][$field];
}
}
if (is_array($snapshot['pending'])) {
$snapshot['pending']['values']['client_key'] = strtolower($snapshot['pending']['values']['client_key']);
}
return $snapshot;
}
/** @return array<string,string> */
private function rules(): array
{
$rules = ['snapshot' => 'required|array:values,generation,pending', 'snapshot.values' => 'required|array:title,due_at,assignee_user_id',
'snapshot.generation' => 'required|array:title,due_at,assignee_user_id', 'snapshot.pending' => 'present|nullable|array:values,generation',
'snapshot.pending.values' => 'required_with:snapshot.pending|array:title,due_at,assignee_user_id,client_key',
'snapshot.pending.generation' => 'required_with:snapshot.pending|array:title,due_at,assignee_user_id',
'snapshot.pending.values.client_key' => 'required_with:snapshot.pending|uuid'];
foreach (['title' => 200, 'due_at' => 100, 'assignee_user_id' => 20] as $field => $limit) {
$rules['snapshot.values.'.$field] = 'present|string|max:'.$limit;
$rules['snapshot.generation.'.$field] = 'present|integer|min:0|max:1000000000';
$rules['snapshot.pending.values.'.$field] = 'exclude_without:snapshot.pending|present|string|max:'.$limit;
$rules['snapshot.pending.generation.'.$field] = 'exclude_without:snapshot.pending|present|integer|min:0|max:1000000000';
}
return $rules;
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Services;
use App\Models\CrmTask;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** Personal follow-up only: assignment never grants authority beyond the destination. */
final class DashboardWorkQueue
{
/**
* @param array<string, int> $pages
* @return array<string, mixed>|null
*/
public function forActor(User $actor, array $pages): ?array
{
$now = CarbonImmutable::now()->startOfSecond();
$access = app(PortalProfileAccess::class);
$manager = $actor->can('manage-portal') && ! $access->has($actor, 'auditor');
$groups = [];
if (app(ScopedPortalAdministration::class)->interestOperator($actor)) {
$query = DB::table('interest_requests')->where('assigned_to', $actor->id)->whereNull('deleted_at')
->whereNotIn('status', ['closed', 'cancelled', 'refused', 'converted'])
->select(['id', 'reference', 'next_action as title', 'status', 'next_action_due_at as due_at']);
$groups['interests'] = $this->group($query, 'interests', $pages, $now);
}
if (app(VirtualDesk::class)->installed()) {
$assigned = DB::table('desk_requests')->where('analyst_id', $actor->id);
// Direct request consultation already permits the assigned analyst, independently of decision authority.
if ($manager || $assigned->exists()) {
$query = DB::table('desk_requests as r')->join('desk_cases as c', 'c.id', '=', 'r.case_id')
->where('r.analyst_id', $actor->id)->whereNull('c.closed_at')
->whereNotIn('r.status', ['draft', 'cancelled', 'closed'])
->select(['r.id', 'r.protocol as reference', 'c.title', 'r.status', 'r.due_at']);
$groups['desk'] = $this->group($query, 'desk', $pages, $now);
}
}
if ($manager && Schema::hasTable('tasks') && Schema::hasTable('leads')) {
$query = CrmTask::availableForWork()->join('leads as l', 'l.id', '=', 'tasks.lead_id')
->where('tasks.assignee_user_id', $actor->id)->whereNull('l.deleted_at')
->select(['tasks.id', 'tasks.title', 'tasks.status', 'tasks.due_at', 'l.id as parent_id', 'l.title as reference']);
$groups['crm_tasks'] = $this->group($query->toBase(), 'crm_tasks', $pages, $now);
}
return $groups === [] ? null : ['as_of' => $now->toISOString(), 'groups' => $groups, 'desk_states' => config('virtual-desk.request_states')];
}
/**
* @param 'interests'|'desk'|'crm_tasks' $kind
* @param array<string, int> $pages
* @return array<string, mixed>
*/
private function group(Builder $query, string $kind, array $pages, CarbonImmutable $now): array
{
[$deadline, $order, $idColumn] = match ($kind) {
'interests' => ['next_action_due_at', 'next_action_due_at ASC NULLS LAST', 'id'],
'desk' => ['r.due_at', 'r.due_at ASC NULLS LAST', 'r.id'],
'crm_tasks' => ['tasks.due_at', 'tasks.due_at ASC NULLS LAST', 'tasks.id'],
};
$pageName = $kind.'_page';
$page = $pages[$pageName] ?? 1;
$total = (clone $query)->count();
$overdue = (clone $query)->where($deadline, '<', $now)->count();
$lastPage = max(1, (int) ceil($total / 5));
// An assignment may disappear between visits. Recover to an existing page instead of trapping a stale URL.
$page = min($page, $lastPage);
$items = (clone $query)->orderByRaw($order)->orderBy($idColumn)
->offset(($page - 1) * 5)->limit(5)->get()->map(function (\stdClass $row) use ($kind, $now): array {
$due = $row->due_at === null ? null : CarbonImmutable::parse($row->due_at);
return ['id' => (string) $row->id, 'title' => $row->title ?: 'Acompanhar manifestação', 'reference' => $row->reference,
'status' => $row->status, 'due_at' => $due?->toISOString(), 'overdue' => $due?->lessThan($now) ?? false,
'href' => match ($kind) {
'interests' => '/management/interests/'.$row->id,
'desk' => '/balcao-virtual/pedidos/'.$row->id,
'crm_tasks' => '/management/crm/leads/'.$row->parent_id.'#crm-task-'.$row->id,
}];
})->all();
return ['total' => $total, 'overdue' => $overdue, 'items' => $items, 'page' => $page, 'last_page' => $lastPage,
'previous' => $page > 1 ? route('dashboard', [...$pages, $pageName => $page - 1], absolute: false) : null,
'next' => $page < $lastPage ? route('dashboard', [...$pages, $pageName => $page + 1], absolute: false) : null];
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
final class DeskAttachments
{
public const MIME_TYPES = ['application/pdf', 'image/png', 'image/jpeg', 'text/plain'];
public function scopedLinksInstalled(): bool
{
$indexes = DB::selectOne("SELECT to_regclass('public.desk_documents_scope_unique') AS scope, to_regclass('public.desk_documents_dossier_unique') AS dossier");
return $indexes->scope !== null && $indexes->dossier !== null;
}
/** @param array<string,mixed> $definition
* @return array<string,mixed> */
public function field(array $definition, string $key): array
{
$field = $definition['fields'][$key] ?? null;
if (! $field || $field['type'] !== 'attachment') {
throw ValidationException::withMessages(['field_key' => 'Seleccione uma pergunta de anexo da versão deste pedido.']);
}
return $field;
}
/** @param array<string,mixed> $field */
public function checkMetadata(\stdClass $document, array $field, string $path, bool $allowUnknown = false): void
{
$mime = strtolower(trim(explode(';', (string) ($document->media_type ?? ''), 2)[0]));
if (($mime === '' && ! $allowUnknown) || ($mime !== '' && ! in_array($mime, $field['mime_types'], true)) || ($document->size_bytes === null && ! $allowUnknown) || ($document->size_bytes !== null && ($document->size_bytes === false || $document->size_bytes < 0 || $document->size_bytes > $field['max_size_kb'] * 1024))) {
throw ValidationException::withMessages([$path => 'O ficheiro não corresponde aos formatos ou ao tamanho máximo desta pergunta. Escolha outro suporte; o original é conservado.']);
}
}
/** Validate references through the existing case/request boundary; no parallel document store.
* @param array<string,mixed> $payload
* @param array<string,mixed> $definition
* @return array<string,mixed> */
public function snapshot(\stdClass $request, array $payload, array $definition, bool $submitting): array
{
$applicable = app(VirtualDesk::class)->applicableFields($payload, $definition);
$snapshot = [];
$verified = [];
foreach ($definition['fields'] as $key => $field) {
if ($field['type'] !== 'attachment') {
continue;
}
$files = [];
$seen = [];
foreach ($payload[$key] ?? [] as $linkId) {
$document = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')
->where('l.id', $linkId)->where('l.case_id', $request->case_id)->whereNull('d.deleted_at')
->where(fn ($q) => $q->whereNull('l.request_id')->orWhere('l.request_id', $request->id))
->first(['d.*', 'l.id as link_id', 'l.source_reference', 'l.category', 'l.valid_until as declared_valid_until', 'l.stage as linked_stage']);
if (! $document) {
throw ValidationException::withMessages(['payload.'.$key => 'Seleccione documentos associados a este dossier e disponíveis neste pedido.']);
}
if (isset($seen[$document->id])) {
throw ValidationException::withMessages(['payload.'.$key => 'Seleccione cada ficheiro apenas uma vez nesta pergunta. Pode reutilizá-lo noutras perguntas aplicáveis.']);
}
$seen[$document->id] = true;
$active = isset($applicable[$key]);
// Hidden answers are preserved; they are not accepted as applicable evidence.
if ($active) {
$this->checkMetadata($document, $field, 'payload.'.$key, true);
}
$usable = $document->status === 'active' && $document->malware_scan_status === 'clean'
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256));
if ($submitting && $active) {
if (! $usable) {
throw ValidationException::withMessages(['payload.'.$key => 'O suporte seleccionado ainda não está disponível com análise de segurança válida. Actualize o estado dos documentos antes de submeter.']);
}
if (! isset($verified[$document->id])) {
try {
$verificationDocument = clone $document;
$verificationDocument->media_type = $verificationDocument->media_type ?: 'application/octet-stream';
$response = app(VerifiedDocumentBinary::class)->respond($verificationDocument);
if ($response instanceof BinaryFileResponse) {
$verified[$document->id] = ['size_bytes' => $response->getFile()->getSize(), 'media_type' => (new \finfo(FILEINFO_MIME_TYPE))->file($response->getFile()->getPathname())];
} else {
$content = $response->getContent();
if (! is_string($content)) {
throw ValidationException::withMessages(['payload.'.$key => 'Não foi possível conferir o original deste suporte.']);
}
$verified[$document->id] = ['size_bytes' => strlen($content), 'media_type' => (new \finfo(FILEINFO_MIME_TYPE))->buffer($content)];
}
} catch (HttpExceptionInterface $error) {
throw ValidationException::withMessages(['payload.'.$key => 'O suporte não está disponível com a autorização e integridade exigidas. Recupere ou seleccione outro documento.']);
}
}
$this->checkMetadata((object) $verified[$document->id], $field, 'payload.'.$key);
$recordedMime = strtolower(trim(explode(';', (string) ($document->media_type ?? ''), 2)[0]));
if (($document->size_bytes !== null && $verified[$document->id]['size_bytes'] !== (int) $document->size_bytes) || ($recordedMime !== '' && $verified[$document->id]['media_type'] !== $recordedMime)) {
throw ValidationException::withMessages(['payload.'.$key => 'O formato ou tamanho do original diverge dos dados registados. Recupere ou seleccione outro documento.']);
}
}
$measured = $submitting && $active ? $verified[$document->id] : null;
$files[] = ['link_id' => $document->link_id, 'document_id' => $document->id, 'file_name' => $document->file_name,
'sha256' => trim($document->sha256), 'version_no' => $document->version_no, 'media_type' => $measured['media_type'] ?? $document->media_type,
'size_bytes' => $measured['size_bytes'] ?? $document->size_bytes, 'recorded_media_type' => $document->media_type, 'recorded_size_bytes' => $document->size_bytes, 'metadata_measured' => $measured !== null, 'source_reference' => $document->source_reference,
'valid_until' => $document->declared_valid_until, 'stage' => $document->linked_stage, 'status' => $document->status, 'malware_scan_status' => $document->malware_scan_status, 'usable_at_submission' => $submitting && $active && $usable];
}
$snapshot[$key] = ['label' => $field['label'], 'applicable' => isset($applicable[$key]), 'service_version_id' => $request->service_version_id,
'limits' => ['mime_types' => $field['mime_types'], 'max_size_kb' => $field['max_size_kb'], 'max_files' => $field['max_files']], 'files' => $files];
}
return $snapshot;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
/** Payment facts stay in desk_payments; the existing encrypted events preserve documentary uses. */
class DeskPaymentEvidence
{
/** @param array<string,mixed> $input
* @return array<string,mixed>|null
*/
public function snapshot(string $caseId, string $requestId, array $input): ?array
{
if (! ($input['document_link_id'] ?? null) && ! ($input['document_id'] ?? null)) {
return null;
}
$document = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')
->where('l.case_id', $caseId)->whereNull('d.deleted_at')
->where(fn ($q) => $q->whereNull('l.request_id')->orWhere('l.request_id', $requestId))
->when($input['document_link_id'] ?? null, fn ($q, $id) => $q->where('l.id', $id))
->when($input['document_id'] ?? null, fn ($q, $id) => $q->where('d.id', $id))
->orderBy('l.created_at')->orderBy('l.id')->first(['d.*', 'l.id as link_id', 'l.source_reference', 'l.category', 'l.stage', 'l.valid_until as declared_valid_until']);
abort_unless($document !== null, 422, 'Seleccione um comprovativo associado a este pedido ou ao seu dossier.');
app(VerifiedDocumentBinary::class)->authorizeOpportunityAttachment($document);
return ['link_id' => $document->link_id, 'document_id' => $document->id, 'file_name' => $document->file_name, 'document_version' => (int) $document->version_no,
'sha256' => trim($document->sha256), 'source_reference' => $document->source_reference, 'category' => $document->category, 'stage' => $document->stage,
'valid_until' => $document->declared_valid_until, 'status_at_association' => $document->status, 'scan_at_association' => $document->malware_scan_status,
'scope' => 'registered_document_metadata_not_binary_or_payment_confirmation'];
}
/** @return array<string,array<string,mixed>> */
public function forRequest(string $requestId): array
{
$current = [];
foreach (DB::table('desk_events')->where('request_id', $requestId)->whereIn('action', ['payment_declared', 'payment_document_linked'])->orderBy('event_order')->get(['payload']) as $event) {
$payload = app(VirtualDesk::class)->decode($event->payload);
if (is_string($payload['payment_id'] ?? null) && is_array($payload['document_evidence'] ?? null)) {
$current[$payload['payment_id']] = $payload['document_evidence'];
}
}
return $current;
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
final class DeskRequestCheckpoints
{
public function installed(): bool
{
return Schema::hasTable('desk_request_checkpoints');
}
/** @return array<string,mixed> */
public function show(User $actor, string $id): array
{
$desk = app(VirtualDesk::class);
$request = $desk->request($actor, $id, true);
$case = $desk->case($actor, $request->case_id, true);
$row = $this->installed() ? DB::table('desk_request_checkpoints')->where('request_id', $id)->where('actor_id', $actor->id)->first() : null;
return ['installed' => $this->installed(), 'request' => ['id' => $id, 'version' => (int) $request->version, 'status' => $request->status,
'protocol' => $request->protocol, 'analyst_id' => $request->analyst_id, 'due_at' => $request->due_at,
'editable_fields' => json_decode($request->editable_fields ?? '[]', true), 'payload' => $desk->decode($request->payload),
'can_edit' => ! $case->closed_at && in_array($request->status, ['draft', 'returned'], true)],
'checkpoint' => $row ? ['version' => (int) $row->version, 'request_version' => (int) $row->request_version,
'updated_at' => $row->updated_at, ...$desk->decode($row->snapshot)] : null];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function save(User $actor, string $id, array $input): array
{
abort_unless($this->installed(), 409, 'A gravação automática requer a actualização de rascunhos. A gravação manual continua disponível.');
$data = Validator::make(['checkpoint' => $input], [
'checkpoint' => 'required|array:version,request_version,operation_key,payload,edited_keys,reason',
'checkpoint.version' => 'required|integer|min:0', 'checkpoint.request_version' => 'required|integer|min:1',
'checkpoint.operation_key' => 'required|uuid', 'checkpoint.payload' => 'present|array',
'checkpoint.edited_keys' => 'present|array|list|max:65', 'checkpoint.edited_keys.*' => 'required|string|distinct|max:100',
'checkpoint.reason' => 'nullable|string|max:3000',
])->validate()['checkpoint'];
return DB::transaction(function () use ($actor, $id, $data): array {
$desk = app(VirtualDesk::class);
$request = $desk->request($actor, $id, true, true);
$case = $desk->case($actor, $request->case_id, true, true);
abort_unless(! $case->closed_at && in_array($request->status, ['draft', 'returned'], true), 409, 'Este pedido já não permite alterar o rascunho.');
abort_unless((int) $request->version === (int) $data['request_version'], 409, 'O pedido mudou. Compare a versão actual antes de recuperar as suas alterações.');
$payload = $desk->checkpointFields($data['payload'], $desk->definition($request));
app(DeskAttachments::class)->snapshot($request, $payload, $desk->definition($request), false);
$allowed = [...array_keys($desk->definition($request)['fields']), 'country', 'declared_origin_country_code', 'context_reuse', 'context_opportunity', 'context_opportunity_snapshot'];
Validator::make($data, ['edited_keys.*' => [Rule::in($allowed)]])->validate();
$base = $desk->decode($request->payload);
$edited = array_values(array_unique([...$data['edited_keys'], ...array_filter(array_unique([...array_keys($base), ...array_keys($payload)]), fn ($key) => ($base[$key] ?? null) !== ($payload[$key] ?? null))]));
if ($request->status === 'returned') {
$editable = json_decode($request->editable_fields ?? '[]', true);
foreach (array_unique([...array_keys($base), ...array_keys($payload), ...$data['edited_keys']]) as $key) {
abort_if(! in_array($key, $editable, true) && ((($base[$key] ?? null) !== ($payload[$key] ?? null)) || in_array($key, $data['edited_keys'], true)), 422, 'O rascunho só pode corrigir os campos autorizados.');
}
}
$operationHash = hash('sha256', json_encode(Arr::sortRecursive(['version' => (int) $data['version'], 'request_version' => (int) $data['request_version'],
'operation_key' => $data['operation_key'], 'payload' => $payload, 'edited_keys' => $data['edited_keys'], 'reason' => $data['reason'] ?? '']), JSON_THROW_ON_ERROR));
$row = DB::table('desk_request_checkpoints')->where('request_id', $id)->where('actor_id', $actor->id)->lockForUpdate()->first();
if ($row && $row->operation_key === $data['operation_key']) {
abort_unless(hash_equals($row->operation_sha256, $operationHash), 409, 'Esta tentativa já foi usada com outros dados.');
return $this->show($actor, $id);
}
abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'O rascunho mudou noutro separador. Compare antes de substituir.');
$version = (int) ($row->version ?? 0) + 1;
DB::table('desk_request_checkpoints')->updateOrInsert(['request_id' => $id, 'actor_id' => $actor->id], [
'version' => $version, 'request_version' => $request->version, 'operation_key' => $data['operation_key'], 'operation_sha256' => $operationHash,
'snapshot' => $desk->encode(['payload' => $payload, 'base_payload' => $base, 'edited_keys' => $edited, 'reason' => $data['reason'] ?? '']),
'created_at' => $row->created_at ?? now(), 'updated_at' => now(),
]);
DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => 'desk_request_checkpoints', 'entity_id' => $id,
'actor_user_id' => $actor->id, 'action' => 'draft_checkpoint_saved', 'data_classification' => 'restricted',
'after_data' => json_encode(['version' => $version, 'request_version' => (int) $request->version], JSON_THROW_ON_ERROR)]);
return $this->show($actor, $id);
});
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Services;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** One applicable edition per service. Existing requests keep their immutable version FK. */
final class DeskServiceAvailability
{
public function scheduled(): bool
{
return Schema::hasColumn('desk_service_versions', 'effective_from');
}
public function current(): Builder
{
$query = DB::table('desk_service_versions')->where('status', 'published')
->where(fn (Builder $date) => $date->whereNull('published_at')->orWhere('published_at', '<=', now()))
->selectRaw('DISTINCT ON (code) *')->orderBy('code');
if ($this->scheduled()) {
$query->where(fn (Builder $date) => $date->whereNull('effective_from')->orWhere('effective_from', '<=', now()->toDateString()))
->orderByRaw('effective_from DESC NULLS LAST');
}
return $query->orderByDesc('version');
}
public function upcoming(): Builder
{
$query = DB::table('desk_service_versions');
if (! $this->scheduled()) {
return $query->whereRaw('false')->select('code', 'version')->selectRaw('NULL::date AS effective_from');
}
return $query->where('status', 'published')->where('effective_from', '>', now()->toDateString())
->where(fn (Builder $date) => $date->whereNull('published_at')->orWhere('published_at', '<=', now()))
->selectRaw('DISTINCT ON (code,effective_from) code,version,effective_from')->orderBy('code')->orderBy('effective_from')->orderByDesc('version');
}
/** @return literal-string */
public function dateProjection(): string
{
return $this->scheduled() ? 'effective_from' : 'NULL::date AS effective_from';
}
}
+90 -13
View File
@@ -15,18 +15,26 @@ final class DeskServiceEditor
/** @return list<array<string,mixed>> */
public function listing(): array
{
$availability = app(DeskServiceAvailability::class);
$active = $availability->current()->get()->keyBy('code');
$upcoming = $availability->upcoming()->get()->groupBy('code')->map(fn ($rows) => $rows->map(fn ($row) => ['version' => (int) $row->version, 'effective_from' => $row->effective_from])->all());
$rows = DB::table('desk_service_versions')->selectRaw('DISTINCT ON (code) code, version, definition')
->selectRaw($availability->dateProjection())
->orderBy('code')->orderByDesc('version')->get()->keyBy('code');
$services = [];
foreach (config('virtual-desk.services') as $code => $initial) {
$row = $rows->get($code);
$services[] = ['code' => $code, 'version' => (int) ($row->version ?? 0), 'editable' => true,
'effective_from' => $row->effective_from ?? null, 'activeVersion' => (int) ($active->get($code)->version ?? 0),
'upcoming' => $upcoming->get($code, []),
'fixedTypes' => array_map(fn (array $field) => $field['type'], $initial['fields']),
'definition' => $row ? json_decode($row->definition, true, 512, JSON_THROW_ON_ERROR) : $initial];
}
foreach ($rows as $code => $row) {
if (! array_key_exists($code, config('virtual-desk.services'))) {
$services[] = ['code' => $code, 'version' => (int) $row->version, 'editable' => false,
'effective_from' => $row->effective_from ?? null, 'activeVersion' => (int) ($active->get($code)->version ?? 0),
'upcoming' => $upcoming->get($code, []),
'definition' => json_decode($row->definition, true, 512, JSON_THROW_ON_ERROR)];
}
}
@@ -42,7 +50,8 @@ final class DeskServiceEditor
$total = (clone $query)->count();
$pages = max(1, (int) ceil($total / 10));
$page = min(max(1, $requestedPage), $pages);
$rows = $query->orderByDesc('version')->forPage($page, 10)->get(['id', 'version', 'status', 'author_id', 'published_by', 'published_at', 'definition', 'source']);
$rows = $query->orderByDesc('version')->forPage($page, 10)->select(['id', 'version', 'status', 'author_id', 'published_by', 'published_at', 'definition', 'source'])
->selectRaw(app(DeskServiceAvailability::class)->dateProjection())->get();
$events = DB::table('audit_events')->where('source_system', 'virtual-desk')->where('entity_type', 'desk_service_versions')
->where('action', 'service_definition_version_published')->whereIn('entity_id', $rows->pluck('id')->all())
->get(['entity_id', 'after_data'])->keyBy('entity_id');
@@ -54,6 +63,7 @@ final class DeskServiceEditor
return ['id' => $row->id, 'version' => (int) $row->version, 'status' => $row->status,
'author_id' => $row->author_id, 'published_by' => $row->published_by, 'published_at' => $row->published_at,
'definition' => json_decode($row->definition, true, 512, JSON_THROW_ON_ERROR),
'effective_from' => $row->effective_from,
'source' => json_decode($row->source, true, 512, JSON_THROW_ON_ERROR), 'reason' => $audit['reason'] ?? null];
})->all()];
}
@@ -64,6 +74,7 @@ final class DeskServiceEditor
abort_unless(array_key_exists($code, config('virtual-desk.services')), 404);
$data = Validator::make($input, [
'base_version' => ['required', 'integer', 'min:0'], 'confirm' => ['accepted'],
'effective_from' => ['nullable', 'date_format:Y-m-d'],
'reason' => ['required', 'string', 'min:20', 'max:2000'],
'definition' => ['required', 'array:title,theme,profile,stage,entity,result,fields'],
'definition.title' => ['required', 'string', 'max:200'],
@@ -73,14 +84,20 @@ final class DeskServiceEditor
'definition.entity' => ['required', 'string', 'max:300'],
'definition.result' => ['required', 'string', 'max:2000'],
'definition.fields' => ['required', 'array', 'min:1', 'max:60'],
'definition.fields.*' => ['required', 'array:label,type,required,min,options,when'],
'definition.fields.*' => ['required', 'array:label,type,required,min,options,when,statement,mime_types,max_size_kb,max_files'],
'definition.fields.*.label' => ['required', 'string', 'max:300'],
'definition.fields.*.type' => ['required', Rule::in(['text', 'textarea', 'email', 'tel', 'url', 'number', 'date', 'select', 'territory'])],
'definition.fields.*.type' => ['required', Rule::in(['text', 'textarea', 'email', 'tel', 'url', 'number', 'date', 'select', 'multiselect', 'consent', 'territory', 'attachment'])],
'definition.fields.*.mime_types' => ['sometimes', 'array', 'list', 'min:1', 'max:4'],
'definition.fields.*.mime_types.*' => ['required', 'string', Rule::in(DeskAttachments::MIME_TYPES)],
'definition.fields.*.max_size_kb' => ['sometimes', 'integer', 'min:1', 'max:10240'],
'definition.fields.*.max_files' => ['sometimes', 'integer', 'min:1', 'max:100'],
'definition.fields.*.statement' => ['sometimes', 'string', 'min:3', 'max:5000'],
'definition.fields.*.required' => ['required', 'boolean'],
'definition.fields.*.min' => ['sometimes', 'numeric', 'min:0', 'max:999999999999'],
'definition.fields.*.options' => ['sometimes', 'array', 'min:1', 'max:100'],
'definition.fields.*.options.*' => ['required', 'string', 'max:300'],
'definition.fields.*.when' => ['sometimes', 'array:field,equals', 'required_array_keys:field,equals'],
'definition.fields.*.when' => ['sometimes', 'array:field,equals,operator', 'required_array_keys:field,equals'],
'definition.fields.*.when.operator' => ['sometimes', Rule::in(['equals', 'contains'])],
'definition.fields.*.when.field' => ['required_with:definition.fields.*.when', 'string', 'max:64'],
'definition.fields.*.when.equals' => ['required_with:definition.fields.*.when', 'string', 'max:1000'],
])->validate();
@@ -88,6 +105,10 @@ final class DeskServiceEditor
return DB::transaction(function () use ($actor, $code, $data): \stdClass {
DB::select('select pg_advisory_xact_lock(261008, 2)');
$scheduled = app(DeskServiceAvailability::class)->scheduled();
if (! $scheduled && ($data['effective_from'] ?? null) !== null) {
throw ValidationException::withMessages(['effective_from' => 'Instale a actualização das datas de entrada em vigor antes de agendar uma versão.']);
}
$latest = DB::table('desk_service_versions')->where('code', $code)->orderByDesc('version')->first();
$previous = $latest ? json_decode($latest->definition, true, 512, JSON_THROW_ON_ERROR) : config('virtual-desk.services.'.$code);
if ($data['definition']['stage'] !== $previous['stage']) {
@@ -100,16 +121,23 @@ final class DeskServiceEditor
}
// Retry of the same confirmed publication preserves the first version and authorship.
if ($latest && (int) $latest->version === (int) $data['base_version'] + 1 && $latest->status === 'published'
&& $this->sameEffectiveRequest($latest, $data, $scheduled)
&& $this->definitionHash(json_decode($latest->definition, true, 512, JSON_THROW_ON_ERROR)) === $this->definitionHash($data['definition'])) {
return $latest;
}
abort_unless((int) ($latest->version ?? 0) === (int) $data['base_version'], 409, 'O formulário foi alterado. Recarregue e compare a versão actual antes de publicar.');
if ($latest && $latest->status === 'published' && $this->definitionHash($previous) === $this->definitionHash($data['definition'])) {
throw ValidationException::withMessages(['definition' => 'Não há alterações na definição para publicar uma nova versão.']);
if (($data['effective_from'] ?? null) !== null && $data['effective_from'] < now()->toDateString()) {
throw ValidationException::withMessages(['effective_from' => 'Uma nova publicação começa hoje ou numa data futura; não altera a vigência de pedidos anteriores.']);
}
$effective = ($data['effective_from'] ?? null) ?: now()->toDateString();
if ($latest && $latest->status === 'published' && $this->definitionHash($previous) === $this->definitionHash($data['definition'])
&& (! $scheduled || ($latest->effective_from ?? null) === $effective || (! ($data['effective_from'] ?? null) && (! ($latest->effective_from ?? null) || $latest->effective_from <= now()->toDateString())))) {
throw ValidationException::withMessages(['definition' => 'Não há alterações na definição ou na data de início para publicar uma nova versão.']);
}
$id = (string) Str::uuid();
DB::table('desk_service_versions')->insert([
'id' => $id, 'code' => $code, 'version' => (int) $data['base_version'] + 1, 'status' => 'published',
...($scheduled ? ['effective_from' => $effective] : []),
'definition' => json_encode($data['definition'], JSON_THROW_ON_ERROR),
'source' => $latest->source ?? json_encode(config('virtual-desk.source'), JSON_THROW_ON_ERROR),
'author_id' => $actor->id, 'published_by' => $actor->id, 'published_at' => now(), 'created_at' => now(), 'updated_at' => now(),
@@ -119,7 +147,10 @@ final class DeskServiceEditor
'action' => 'service_definition_version_published',
'before_data' => json_encode(['id' => $latest?->id, 'version' => (int) $data['base_version']], JSON_THROW_ON_ERROR),
'after_data' => json_encode(['code' => $code, 'version' => (int) $data['base_version'] + 1, 'reason' => $data['reason'],
'effect' => 'new_requests_only', 'definition_sha256' => $this->definitionHash($data['definition'])], JSON_THROW_ON_ERROR),
'effect' => 'new_requests_only', 'effective_from' => $scheduled ? $effective : null,
'effective_request_mode' => ($data['effective_from'] ?? null) ? 'dated' : 'immediate',
'selection' => 'latest_started_effective_date_then_version; undated_legacy_fallback',
'definition_sha256' => $this->definitionHash($data['definition'])], JSON_THROW_ON_ERROR),
]);
return DB::table('desk_service_versions')->where('id', $id)->firstOrFail();
@@ -132,20 +163,59 @@ final class DeskServiceEditor
return hash('sha256', json_encode(Arr::sortRecursive($definition), JSON_THROW_ON_ERROR));
}
/** PostgreSQL JSONB does not retain object-key order.
* @param array<array-key,mixed> $left
* @param array<array-key,mixed> $right */
public function sameDefinition(array $left, array $right): bool
{
return $this->definitionHash($left) === $this->definitionHash($right);
}
/** @param array<string,mixed> $data */
private function sameEffectiveRequest(\stdClass $latest, array $data, bool $scheduled): bool
{
if (! $scheduled || ($latest->effective_from ?? null) === null) {
return ! ($data['effective_from'] ?? null);
}
if ($data['effective_from'] ?? null) {
return $latest->effective_from === $data['effective_from'];
}
$event = DB::table('audit_events')->where('source_system', 'virtual-desk')->where('entity_type', 'desk_service_versions')
->where('entity_id', $latest->id)->where('action', 'service_definition_version_published')->orderByDesc('id')->value('after_data');
return $event && (json_decode($event, true, 512, JSON_THROW_ON_ERROR)['effective_request_mode'] ?? null) === 'immediate';
}
/** @param array<string,array<string,mixed>> $fields */
private function checkFields(array $fields): void
{
foreach ($fields as $key => $field) {
$path = 'definition.fields.'.$key;
if ($field['type'] === 'attachment') {
if (count(array_unique($field['mime_types'] ?? [])) !== count($field['mime_types'] ?? [])) {
throw ValidationException::withMessages([$path.'.mime_types' => 'Escolha cada formato apenas uma vez nesta pergunta.']);
}
if (! isset($field['mime_types'],$field['max_size_kb'],$field['max_files'])) {
throw ValidationException::withMessages([$path => 'Defina os formatos, tamanho por ficheiro e quantidade máxima desta pergunta.']);
}
if (! app(DeskAttachments::class)->scopedLinksInstalled()) {
throw ValidationException::withMessages([$path => 'Instale a actualização dos vínculos documentais antes de publicar perguntas de anexos. Os formulários existentes continuam disponíveis.']);
}
} elseif (array_intersect(['mime_types', 'max_size_kb', 'max_files'], array_keys($field))) {
throw ValidationException::withMessages([$path => 'Os limites de ficheiros pertencem apenas a perguntas de anexos.']);
}
if (! preg_match('/^[a-z][a-z0-9_]{0,63}$/D', (string) $key) || in_array($key, ['country', 'declared_origin_country_code', 'context_reuse', 'context_opportunity', 'context_opportunity_snapshot'], true)) {
throw ValidationException::withMessages(['definition.fields' => 'Use identificadores de campo distintos dos campos comuns de país e contexto, com letras minúsculas, números e sublinhado.']);
}
if ($field['type'] === 'select' && empty($field['options'])) {
if (in_array($field['type'], ['select', 'multiselect'], true) && empty($field['options'])) {
throw ValidationException::withMessages([$path.'.options' => 'Indique as opções deste campo.']);
}
if ((isset($field['min']) && $field['type'] !== 'number') || (isset($field['options']) && $field['type'] !== 'select')) {
if ((isset($field['min']) && $field['type'] !== 'number') || (isset($field['options']) && ! in_array($field['type'], ['select', 'multiselect'], true))) {
throw ValidationException::withMessages([$path => 'O mínimo aplica-se apenas a números e as opções apenas a listas de respostas.']);
}
if (($field['type'] === 'consent' && trim($field['statement'] ?? '') === '') || (isset($field['statement']) && $field['type'] !== 'consent')) {
throw ValidationException::withMessages([$path.'.statement' => 'Uma declaração de consentimento exige o texto exacto a apresentar; esse texto pertence apenas a campos de consentimento.']);
}
foreach (array_keys($field['options'] ?? []) as $option) {
if (! preg_match('/^[a-zA-Z0-9_-]{1,80}$/D', (string) $option)) {
throw ValidationException::withMessages([$path.'.options' => 'Use códigos de opção sem espaços ou pontuação especial.']);
@@ -158,12 +228,19 @@ final class DeskServiceEditor
if (! isset($fields[$parent]) || in_array($parent, $visited, true)) {
throw ValidationException::withMessages([$path.'.when' => 'A condição deve referir outro campo existente, sem dependências circulares.']);
}
if ($fields[$parent]['type'] === 'number') {
throw ValidationException::withMessages([$path.'.when' => 'As condições actuais comparam respostas exactas. Use uma lista de opções como origem da condição numérica.']);
if (in_array($fields[$parent]['type'], ['number', 'attachment'], true)) {
throw ValidationException::withMessages([$path.'.when' => 'Use uma resposta de texto, lista ou declaração como origem da condição; números e anexos não são respostas comparáveis por estas condições.']);
}
if ($fields[$parent]['type'] === 'select' && ! array_key_exists($node['when']['equals'], $fields[$parent]['options'] ?? [])) {
$operator = $node['when']['operator'] ?? 'equals';
if (($fields[$parent]['type'] === 'multiselect') !== ($operator === 'contains')) {
throw ValidationException::withMessages([$path.'.when' => 'Use inclusão de uma opção para uma selecção múltipla e igualdade para respostas simples.']);
}
if (in_array($fields[$parent]['type'], ['select', 'multiselect'], true) && ! array_key_exists($node['when']['equals'], $fields[$parent]['options'] ?? [])) {
throw ValidationException::withMessages([$path.'.when' => 'O valor da condição deve corresponder a uma opção do campo escolhido.']);
}
if ($fields[$parent]['type'] === 'consent' && ! in_array($node['when']['equals'], ['accepted', 'declined'], true)) {
throw ValidationException::withMessages([$path.'.when' => 'Compare a declaração com accepted ou declined; uma resposta por preencher não é aceitação.']);
}
$visited[] = $parent;
$node = $fields[$parent];
}
@@ -188,7 +265,7 @@ final class DeskServiceEditor
$conditions = [];
while (isset($fields[$key]['when'])) {
$when = $fields[$key]['when'];
$conditions[$when['field']] = $when['equals'];
$conditions[$when['field']] = ($when['operator'] ?? 'equals').':'.$when['equals'];
$key = $when['field'];
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Services;
use App\Models\User;
/** Existing internal administration; does not establish an institutional mandate. */
final class EducationAccess
{
public function allows(User $actor): bool
{
return $actor->hasVerifiedEmail() && ! app(PortalProfileAccess::class)->has($actor, 'auditor')
&& ($actor->can('manage-portal') || app(ScopedPortalAdministration::class)->administrative($actor));
}
public function authorize(User $actor): void
{
abort_unless(config('education.enabled'), 404);
abort_unless($this->allows($actor), 403);
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Services;
use App\Models\LabourDemand;
use App\Models\TrainingCapacity;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
final class EducationDeclarationDrafts
{
public function installed(): bool
{
return Schema::hasTable('education_declaration_drafts');
}
public function guard(User $actor, string $kind): void
{
abort_unless(config('education.enabled') && $this->installed() && in_array($kind, ['demands', 'capacities'], true), 404);
app(EducationAccess::class)->authorize($actor);
}
/** @return array<string,mixed> */
public function show(User $actor, string $kind, string $id): array
{
$this->guard($actor, $kind);
$row = DB::table('education_declaration_drafts')->where('id', $id)->where('owner_user_id', $actor->id)->where('kind', $kind)->firstOrFail();
return ['id' => $id, 'kind' => $kind, 'version_no' => (int) $row->version_no, 'state' => $row->state,
'payload' => $this->decode($row->payload),
'references' => $row->reference_snapshot ? json_decode(Crypt::decryptString($row->reference_snapshot), true, 512, JSON_THROW_ON_ERROR) : null,
'record_id' => $row->demand_id ?? $row->capacity_id, 'updated_at' => CarbonImmutable::parse($row->updated_at)->toIso8601String()];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function save(User $actor, string $kind, ?string $id, array $input): array
{
$this->guard($actor, $kind);
$data = Validator::make($input, ['version_no' => 'required|integer|min:0', 'client_key' => $id ? 'sometimes|uuid' : 'required|uuid', 'payload' => 'present|array'])->validate();
$payload = $this->partial($kind, $data['payload']);
return DB::transaction(function () use ($actor, $kind, $id, $data, $payload): array {
DB::table('users')->where('id', $actor->id)->lock('FOR NO KEY UPDATE')->firstOrFail();
$query = DB::table('education_declaration_drafts')->where('owner_user_id', $actor->id);
$row = $id ? $query->where('id', $id)->where('kind', $kind)->lockForUpdate()->firstOrFail()
: $query->where('client_key', $data['client_key'])->lockForUpdate()->first();
abort_if($row && ($row->kind !== $kind || $row->state !== 'draft'), 409, 'Este rascunho já foi submetido ou pertence a outro tipo de declaração.');
if ($row && Arr::sortRecursive($this->decode($row->payload)) === Arr::sortRecursive($payload)
&& in_array((int) $data['version_no'], [(int) $row->version_no, (int) $row->version_no - 1], true)) {
return $this->show($actor, $kind, $row->id);
}
abort_unless((int) ($row->version_no ?? 0) === (int) $data['version_no'], 409, 'O rascunho mudou. Recupere a versão actual antes de guardar.');
$id = $row->id ?? (string) Str::uuid();
DB::table('education_declaration_drafts')->updateOrInsert(['id' => $id], ['owner_user_id' => $actor->id, 'client_key' => $row->client_key ?? $data['client_key'],
'kind' => $kind, 'version_no' => (int) ($row->version_no ?? 0) + 1, 'payload' => Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)),
'state' => 'draft', 'created_at' => $row->created_at ?? now(), 'updated_at' => now()]);
$this->audit($actor, $id, 'draft_saved', (int) ($row->version_no ?? 0) + 1, $kind);
return $this->show($actor, $kind, $id);
});
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function submit(User $actor, string $kind, string $id, array $input): array
{
$this->guard($actor, $kind);
$data = Validator::make($input, ['version_no' => 'required|integer|min:1', 'payload' => 'present|array'])->validate();
$payload = $this->partial($kind, $data['payload']);
return DB::transaction(function () use ($actor, $kind, $id, $data, $payload): array {
DB::table('users')->where('id', $actor->id)->lock('FOR NO KEY UPDATE')->firstOrFail();
$row = DB::table('education_declaration_drafts')->where('id', $id)->where('owner_user_id', $actor->id)->where('kind', $kind)->lockForUpdate()->firstOrFail();
if ($row->state === 'submitted') {
abort_unless(in_array((int) $data['version_no'], [(int) $row->version_no, (int) $row->version_no - 1], true)
&& Arr::sortRecursive($this->decode($row->payload)) === Arr::sortRecursive($payload), 409);
return $this->show($actor, $kind, $id);
}
abort_unless((int) $row->version_no === (int) $data['version_no'], 409, 'O rascunho mudou. Recupere a versão actual antes de submeter.');
$validated = Validator::make($payload, EducationDeclarationRules::forKind($kind))->validate();
$references = [];
foreach (['occupation_id' => 'occupations', 'skill_id' => 'skills', $kind === 'demands' ? 'project_id' : 'course_id' => $kind === 'demands' ? 'projects' : 'courses'] as $field => $table) {
$source = DB::table($table)->where('id', $validated[$field])->firstOrFail();
$references[$field] = array_intersect_key((array) $source, array_flip(['id', 'name', 'title', 'code', 'provider_id', 'updated_at']));
}
$references['territory_path'] = array_map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'type' => $node->location_type], app(InvestmentGeography::class)->path($validated['geo_location_id'], true));
$record = $kind === 'demands' ? new LabourDemand : new TrainingCapacity;
PortalAudit::save($record, [...$validated, 'submitted_by' => $actor->id, 'status' => 'submitted'], $actor->id);
DB::table('education_declaration_drafts')->where('id', $id)->update(['payload' => Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)),
'reference_snapshot' => Crypt::encryptString(json_encode($references, JSON_THROW_ON_ERROR)),
'state' => 'submitted', 'version_no' => (int) $row->version_no + 1, $kind === 'demands' ? 'demand_id' : 'capacity_id' => $record->id, 'updated_at' => now()]);
$this->audit($actor, $id, 'draft_submitted', (int) $row->version_no + 1, $kind);
return $this->show($actor, $kind, $id);
});
}
/** @param array<string,mixed> $payload
* @return array<string,mixed> */
private function partial(string $kind, array $payload): array
{
unset($payload[$kind === 'demands' ? 'course_id' : 'project_id']);
if ($kind === 'capacities') {
unset($payload['employer']);
}
$rules = ['occupation_id' => 'nullable|uuid', 'skill_id' => 'nullable|uuid', 'geo_location_id' => 'nullable|uuid',
$kind === 'demands' ? 'project_id' : 'course_id' => 'nullable|uuid', 'qualification' => 'nullable|string|max:150',
'quantity' => 'nullable|integer|min:0|max:10000000', 'period_start' => 'nullable|date_format:Y-m-d', 'period_end' => 'nullable|date_format:Y-m-d',
'source' => 'nullable|string|max:5000', 'reference_date' => 'nullable|date_format:Y-m-d', 'valid_until' => 'nullable|date_format:Y-m-d'];
if ($kind === 'demands') {
$rules['employer'] = 'nullable|string|max:200';
}
Validator::make(['payload' => $payload], ['payload' => 'array:'.implode(',', array_keys($rules))])->validate();
return Validator::make($payload, $rules)->validate();
}
private function audit(User $actor, string $id, string $action, int $version, string $kind): void
{
DB::table('audit_events')->insert(['source_system' => 'education-drafts', 'entity_type' => 'education_declaration_drafts', 'entity_id' => $id,
'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted', 'after_data' => json_encode(['version_no' => $version, 'kind' => $kind], JSON_THROW_ON_ERROR)]);
}
/** @return array<string,mixed> */
private function decode(string $encrypted): array
{
$payload = json_decode(Crypt::decryptString($encrypted), true, 512, JSON_THROW_ON_ERROR);
if (! is_array($payload)) {
throw new \UnexpectedValueException('Invalid education draft payload.');
}
return $payload;
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Services;
use App\Models\TrainingProvider;
use Illuminate\Validation\Rule;
final class EducationDeclarationRules
{
/** @return array<string,mixed> */
public static function forKind(string $kind): array
{
abort_unless(in_array($kind, ['demands', 'capacities'], true), 404);
$demand = $kind === 'demands';
return [
'occupation_id' => ['required', 'uuid', Rule::exists('occupations', 'id')->whereNull('deleted_at')],
'skill_id' => ['required', 'uuid', Rule::exists('skills', 'id')->whereNull('deleted_at')],
'geo_location_id' => ['required', 'uuid', Rule::exists('geo_locations', 'id')->whereNull('deleted_at')->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])],
$demand ? 'project_id' : 'course_id' => ['required', 'uuid', $demand ? Rule::exists('projects', 'id')->whereNull('deleted_at')
: Rule::exists('courses', 'id')->whereNull('deleted_at')->whereNot('status', 'retired')->where(fn ($query) => $query->whereIn('provider_id', TrainingProvider::where('status', '!=', 'inactive')->select('id')))],
'employer' => [$demand ? 'required' : 'exclude', 'string', 'max:200'],
'qualification' => ['required', 'string', 'max:150'], 'quantity' => ['required', 'integer', 'min:1', 'max:10000000'],
'period_start' => ['required', 'date_format:Y-m-d'], 'period_end' => ['required', 'date_format:Y-m-d', 'after_or_equal:period_start'],
'source' => ['required', 'string', 'min:10', 'max:5000'], 'reference_date' => ['required', 'date_format:Y-m-d', 'before_or_equal:today'],
'valid_until' => ['required', 'date_format:Y-m-d', 'after_or_equal:reference_date'],
];
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Services;
use App\Models\LabourDemand;
use App\Models\TrainingCapacity;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
final class EducationReviews
{
private const SOURCE = 'portal-education-review';
/** @param array<string,mixed> $input
* @return array{version:int,replayed:bool} */
public function decide(User $actor, string $kind, string $id, array $input): array
{
app(EducationAccess::class)->authorize($actor);
$data = Validator::make($input, ['client_key' => 'nullable|uuid', 'version_no' => 'required|integer|min:1', 'status' => 'required|in:reviewed,rejected', 'review_reason' => 'required|string|min:15|max:3000'])->validate();
$key = is_string($data['client_key'] ?? null) ? strtolower($data['client_key']) : null;
$hash = hash('sha256', json_encode(['version_no' => (int) $data['version_no'], 'status' => $data['status'], 'review_reason' => trim($data['review_reason'])], JSON_THROW_ON_ERROR));
$model = match ($kind) {
'demands' => LabourDemand::class,'capacities' => TrainingCapacity::class,default => abort(404)
};
return DB::transaction(function () use ($actor, $model, $id, $data, $key, $hash) {
$record = $model::lockForUpdate()->findOrFail($id);
if ($key !== null) {
$event = DB::table('audit_events')->where('source_system', self::SOURCE)->where('actor_user_id', $actor->id)->where('entity_type', $record->getTable())->where('entity_id', $id)->where('action', 'review_committed')
->whereRaw("after_data->'education_review'->>'key' = ?", [$key])->selectRaw("after_data->'education_review'->>'hash' AS hash,after_data->'education_review'->'version' AS version")->first();
if ($event) {
abort_unless($event->hash === $hash, 409, 'Esta tentativa de revisão já foi usada com outros dados. Compare antes de continuar.');
$version = is_string($event->version) ? json_decode($event->version, true, 512, JSON_THROW_ON_ERROR) : $event->version;
abort_unless(is_int($version) && $version > 0, 409, 'A revisão anterior não pôde ser identificada com segurança.');
return ['version' => $version, 'replayed' => true];
}
}
abort_if((int) $record->getAttribute('submitted_by') === $actor->id, 403, 'Outro gestor deve efectuar a revisão.');
abort_if((int) $record->getAttribute('version_no') !== (int) $data['version_no'], 409, 'A versão foi alterada. Compare os dados actuais antes de decidir.');
abort_if($record->getAttribute('valid_until') < now()->toDateString(), 422, 'A fonte expirou.');
$version = (int) $data['version_no'] + 1;
$before = ['version' => (int) $record->getAttribute('version_no'), 'status' => $record->getAttribute('status'), 'review_reason' => $record->getAttribute('review_reason'), 'reviewed_by' => $record->getAttribute('reviewed_by')];
$subject = Arr::only($record->getAttributes(), ['id', 'project_id', 'course_id', 'occupation_id', 'skill_id', 'geo_location_id', 'employer', 'qualification', 'quantity', 'period_start', 'period_end', 'source', 'reference_date', 'valid_until', 'submitted_by']);
PortalAudit::save($record, ['status' => $data['status'], 'review_reason' => trim($data['review_reason']), 'version_no' => $version, 'reviewed_by' => $actor->id], $actor->id);
$snapshot = ['subject' => $subject, 'before' => $before, 'after' => ['version' => $version, 'status' => $data['status'], 'review_reason' => $record->getAttribute('review_reason'), 'reviewed_by' => $actor->id]];
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'actor_user_id' => $actor->id, 'entity_type' => $record->getTable(), 'entity_id' => $id, 'action' => 'review_committed', 'data_classification' => 'restricted',
'after_data' => json_encode(['education_review' => ['key' => $key, 'hash' => $key !== null ? $hash : null, 'version' => $version, 'snapshot' => Crypt::encryptString(json_encode($snapshot, JSON_THROW_ON_ERROR))]], JSON_THROW_ON_ERROR)]);
return ['version' => $version, 'replayed' => false];
});
}
/** @return array{actor_id:int,commands:list<array{key:string,version:int}>} */
public function context(User $actor, LabourDemand|TrainingCapacity $record): array
{
app(EducationAccess::class)->authorize($actor);
$commands = [];
foreach (DB::table('audit_events')->where('source_system', self::SOURCE)->where('actor_user_id', $actor->id)->where('entity_type', $record->getTable())->where('entity_id', $record->id)->where('action', 'review_committed')
->selectRaw("after_data->'education_review'->>'key' AS key,after_data->'education_review'->'version' AS version")->get() as $event) {
$version = is_string($event->version) ? json_decode($event->version, true, 512, JSON_THROW_ON_ERROR) : $event->version;
if (is_string($event->key) && is_int($version) && $version > 0) {
$commands[] = ['key' => $event->key, 'version' => $version];
}
}
return ['actor_id' => $actor->id, 'commands' => $commands];
}
/** Paginated private view data; no mutable paginator escapes the service.
* @return array<string,mixed> */
public function history(User $actor, LabourDemand|TrainingCapacity $record): array
{
app(EducationAccess::class)->authorize($actor);
return DB::table('audit_events as event')->leftJoin('users', 'users.id', '=', 'event.actor_user_id')->where('event.source_system', self::SOURCE)
->where('event.entity_type', $record->getTable())->where('event.entity_id', $record->id)->where('event.action', 'review_committed')
->selectRaw("event.id,event.occurred_at,users.name AS actor,event.after_data->'education_review'->'version' AS version,event.after_data->'education_review'->>'snapshot' AS snapshot")
->orderByRaw("CASE WHEN (event.after_data->'education_review'->>'version') ~ '^[0-9]{1,10}$' THEN (event.after_data->'education_review'->>'version')::bigint END DESC NULLS LAST")
->orderByDesc('event.occurred_at')->orderByDesc('event.id')->paginate(10, ['*'], 'history_page')->withQueryString()->through(function ($event) {
$version = is_string($event->version) ? json_decode($event->version, true) : $event->version;
$content = null;
try {
if (is_string($event->snapshot)) {
$content = json_decode(Crypt::decryptString($event->snapshot), true);
}
} catch (DecryptException) {/* Retain metadata and encrypted original; do not invent a past reason. */
}
$available = is_array($content) && isset($content['after']) && is_array($content['after']) && ($content['after']['version'] ?? null) === $version && is_string($content['after']['review_reason'] ?? null)
&& in_array($content['after']['status'] ?? null, ['reviewed', 'rejected'], true);
return ['id' => $event->id, 'version' => is_int($version) && $version > 0 ? $version : null, 'actor' => $event->actor, 'occurred_at' => $event->occurred_at, 'available' => $available,
'status' => $available ? $content['after']['status'] : null, 'reason' => $available ? $content['after']['review_reason'] : null,
'previous_reason' => $available && is_string($content['before']['review_reason'] ?? null) ? $content['before']['review_reason'] : null];
})->toArray();
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\DB;
final class EducationWorkQueue
{
/** @return array{actor_id:int,drafts:int|null,demands:array<string,int>,capacities:array<string,int>} */
public function summary(User $actor): array
{
app(EducationAccess::class)->authorize($actor);
return ['actor_id' => $actor->id, 'drafts' => app(EducationDeclarationDrafts::class)->installed() ? DB::table('education_declaration_drafts')->where('owner_user_id', $actor->id)->where('state', 'draft')->count() : null,
'demands' => $this->counts('labour_demands', $actor), 'capacities' => $this->counts('training_capacities', $actor)];
}
/** @return array<string,int> */
private function counts(string $table, User $actor): array
{
$today = now()->toDateString();
$id = $actor->id;
$row = DB::table($table)->whereNull('deleted_at')->selectRaw("count(*) FILTER(WHERE submitted_by=?) AS total,
count(*) FILTER(WHERE submitted_by=? AND valid_until>=? AND status='submitted') AS submitted,
count(*) FILTER(WHERE submitted_by=? AND valid_until>=? AND status='reviewed') AS reviewed,
count(*) FILTER(WHERE submitted_by=? AND valid_until>=? AND status='rejected') AS rejected,
count(*) FILTER(WHERE submitted_by=? AND valid_until<?) AS expired,
count(*) FILTER(WHERE submitted_by<>? AND valid_until>=? AND status='submitted') AS to_review", [$id, $id, $today, $id, $today, $id, $today, $id, $today, $id, $today])->first();
if ($row === null) {
throw new \LogicException('Education aggregate result missing.');
}
$result = [];
foreach (['total', 'submitted', 'reviewed', 'rejected', 'expired', 'to_review'] as $key) {
$result[$key] = (int) $row->$key;
}
return $result;
}
}
+2 -1
View File
@@ -41,7 +41,7 @@ final class ExistingBusinessDrafts
'payload.declared_reason' => ['nullable', 'string', 'max:2000'],
'payload.source_reference' => ['nullable', 'string', 'max:2000'],
'payload.valid_until' => ['nullable', 'date_format:Y-m-d'],
...BusinessFormSchema::rules(),
...BusinessFormSchema::rules(is_array($input['payload'] ?? null) ? $input['payload'] : []),
])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
@@ -95,6 +95,7 @@ final class ExistingBusinessDrafts
public function snapshot(ExistingBusiness $business, User $actor, string $action, string $reason, array $context = []): void
{
$snapshot = json_encode(['context' => $context, 'record' => $business->attributesToArray(), 'payload' => $business->payload,
'locations' => DB::table('existing_business_locations')->where('existing_business_id', $business->id)->orderBy('id')->select('*')->selectRaw('ST_AsEWKT(geom) AS geometry_snapshot')->get()->map(fn ($row) => (array) $row)->all(),
'objects' => ExistingBusinessObject::where('business_id', $business->id)->orderBy('id')->get()
->map(fn ($object) => [...$object->attributesToArray(), 'payload' => $object->payload])->all(),
], JSON_THROW_ON_ERROR);
+3 -1
View File
@@ -122,6 +122,7 @@ final class ExistingBusinessIntake
$submissionRules['valid_until'][0] = 'required';
}
Validator::make([...$business->payload, 'consent' => $consent], $submissionRules)->validate();
app(BusinessPointPublication::class)->candidate($business->payload);
}
if ($action === OpportunityStatus::Approved->value) {
$levelOneActor = DB::table('business_workflow_transitions')->where('existing_business_id', $business->id)
@@ -138,6 +139,7 @@ final class ExistingBusinessIntake
abort_unless(! in_array((int) $actor->id, $approvalActors, true), 403,
'A publicação deve ser executada por uma pessoa diferente dos aprovadores.');
}
$pointContext = $action === OpportunityStatus::Published->value ? app(BusinessPointPublication::class)->publish($business) : [];
$fromStatus = $business->status;
$business->status = $action;
if ($action === OpportunityStatus::Published->value) {
@@ -190,7 +192,7 @@ final class ExistingBusinessIntake
DB::table('existing_business_reviews')->insert(['id' => (string) Str::uuid(), 'business_id' => $id, 'actor_id' => $actor->id,
'version_no' => $business->version_no, 'action' => $action,
'note' => Crypt::encryptString(json_encode(['note' => $note, 'review_consent' => $action === 'submitted' ? $consent : null], JSON_THROW_ON_ERROR))]);
app(ExistingBusinessDrafts::class)->snapshot($business, $actor, $action, $note);
app(ExistingBusinessDrafts::class)->snapshot($business, $actor, $action, $note, $pointContext);
return $business;
});
+169
View File
@@ -0,0 +1,169 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
/** Source-versioned references, stored in the existing dossier, not a financing workflow. */
final class InvestmentGuides
{
public const EVENT = 'investment_guide_saved';
public function __construct(private readonly VirtualDesk $desk) {}
/** @return array<string,mixed> */
public function guide(string $slug): array
{
$guide = config('investment-guides.'.$slug);
abort_unless(is_array($guide), 404);
return [...$guide, 'sha256' => hash('sha256', json_encode($guide, JSON_THROW_ON_ERROR))];
}
/** Only coded choices pass through public/authentication URLs.
* @param array<string,mixed> $input
* @return array<string,mixed> */
public function selection(array $input): array
{
$v = Validator::make($input, ['slug' => ['required', 'string', Rule::in(array_keys(config('investment-guides')))], 'sha256' => 'required|string|size:64'])->validate();
$guide = $this->guide($v['slug']);
abort_unless(hash_equals($guide['sha256'], $v['sha256']), 409, 'O guia mudou desde a consulta. Abra a edição actual antes de preparar uma nova referência.');
return ['slug' => $v['slug'], 'sha256' => $v['sha256'], 'instruments' => $this->choices($input, $guide)];
}
/** @param array<string,mixed> $input
* @param array<string,mixed> $guide
* @return list<string> */
private function choices(array $input, array $guide): array
{
$v = Validator::make($input, ['instruments' => ['sometimes', 'array', 'list', 'max:20'], 'instruments.*' => ['string', Rule::in(array_keys($guide['instruments']))]])->validate();
$values = array_values(array_unique($v['instruments'] ?? []));
sort($values);
return $values;
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function publicView(string $slug, array $input): array
{
$guide = $this->guide($slug);
return ['guide' => $guide, 'instruments' => $this->choices($input, $guide), 'note' => '', 'case' => null, 'saved' => null,
'canWrite' => false, 'historical' => false, 'olderEdition' => false, 'currentAvailable' => true, 'workingCopy' => false, 'history' => [], 'historyNext' => null, 'contextChanged' => false, 'unavailableInstruments' => []];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function view(User $actor, string $id, string $slug, array $input): array
{
$case = $this->desk->case($actor, $id);
$current = is_array(config('investment-guides.'.$slug)) ? $this->guide($slug) : null;
$v = Validator::make($input, ['revision' => 'nullable|uuid', 'history_before' => 'nullable|integer|min:1', 'current' => 'nullable|boolean', 'selection' => 'nullable|boolean'])->validate();
$saved = $this->desk->decode($case->payload)['investment_guides'][$slug] ?? null;
if (! empty($v['revision'])) {
abort_if(isset($input['instruments']) || ! empty($v['current']) || ! empty($v['selection']), 422, 'A revisão histórica conserva a referência original.');
$row = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->where('id', $v['revision'])->first();
abort_unless($row !== null, 404);
$saved = $this->desk->decode($row->payload)['snapshot'];
abort_unless($saved['guide']['slug'] === $slug, 404);
}
$working = array_key_exists('instruments', $input) || ! empty($v['current']) || ! empty($v['selection']);
abort_unless($current !== null || $saved !== null, 404);
abort_if($working && $current === null, 409, 'O guia foi retirado da consulta actual; as referências guardadas continuam disponíveis.');
if ($working && isset($input['sha256'])) {
$this->selection([...$input, 'slug' => $slug]);
}
$guide = $working || ! $saved ? $current : $saved['guide'];
$selected = array_key_exists('instruments', $input) || ! empty($v['selection']) ? $this->choices($input, $guide)
: ($working ? array_values(array_intersect($saved['instruments'] ?? [], array_keys($guide['instruments']))) : ($saved['instruments'] ?? []));
$events = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)
->when(! empty($v['history_before']), fn ($q) => $q->where('event_order', '<', $v['history_before']))
->orderByDesc('event_order')->cursor();
$history = [];
foreach ($events as $event) {
$snapshot = $this->desk->decode($event->payload)['snapshot'];
if ($snapshot['guide']['slug'] !== $slug) {
continue;
}
$history[] = ['id' => $event->id, 'revision' => $snapshot['revision'], 'at' => $snapshot['at'], 'edition' => $snapshot['guide']['version'], 'order' => $event->event_order];
if (count($history) === 26) {
break;
}
}
$context = app(InvestorContext::class)->state($case);
$unavailable = [];
if ($working && $saved) {
foreach ($saved['instruments'] as $code) {
if (! isset($guide['instruments'][$code])) {
$unavailable[] = $saved['guide']['instruments'][$code]['title'];
}
}
}
return ['guide' => $guide, 'instruments' => $selected, 'note' => $saved['note'] ?? '', 'saved' => $saved,
'case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version],
'canWrite' => ! $case->closed_at && $this->desk->owns($actor, $case), 'historical' => ! empty($v['revision']),
'olderEdition' => $current === null || $guide['sha256'] !== $current['sha256'], 'currentAvailable' => $current !== null, 'workingCopy' => $working,
'contextChanged' => $saved && ($saved['context']['revision'] !== $context['revision'] || $saved['context']['dependency_hash'] !== ($context['dependency_hash'] ?? null)),
'unavailableInstruments' => $unavailable,
'history' => array_slice($history, 0, 25), 'historyNext' => count($history) > 25 ? $history[24]['order'] : null];
}
/** @param array<string,mixed> $input
* @param array<string,mixed> $guide
* @return array<string,mixed> */
private function reference(array $input, array $guide): array
{
$v = Validator::make($input, ['sha256' => ['required', Rule::in([$guide['sha256']])], 'note' => 'nullable|string|max:3000'])->validate();
return ['sha256' => $v['sha256'], 'note' => $v['note'] ?? '', 'instruments' => $this->choices($input, $guide)];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function save(User $actor, string $id, string $slug, array $input): array
{
$this->desk->case($actor, $id, true);
$v = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid'])->validate();
return DB::transaction(function () use ($actor, $id, $slug, $input, $v) {
$case = $this->desk->case($actor, $id, true, true);
foreach (DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->orderByDesc('event_order')->cursor() as $event) {
$data = $this->desk->decode($event->payload);
if ($data['operation_key'] === $v['operation_key']) {
abort_unless($data['snapshot']['guide']['slug'] === $slug, 409);
$reference = $this->reference($input, $data['snapshot']['guide']);
$hash = hash('sha256', json_encode([(int) $v['version'], $slug, $reference], JSON_THROW_ON_ERROR));
abort_unless(hash_equals($data['request_hash'], $hash), 409, 'Esta operação já guardou uma referência diferente.');
return ['saved' => true, 'replayed' => true, 'revision' => $data['snapshot']['revision']];
}
}
$guide = $this->guide($slug);
abort_unless(($input['sha256'] ?? null) === $guide['sha256'], 409, 'O guia mudou. Conserve a nota e abra a edição actual para uma nova referência.');
$reference = $this->reference($input, $guide);
abort_if($case->closed_at || (int) $case->version !== (int) $v['version'], 409, 'O dossier mudou ou foi fechado. Os dados continuam nesta página; confira a edição guardada antes de tentar novamente.');
$payload = $this->desk->decode($case->payload);
$previous = $payload['investment_guides'][$slug] ?? null;
$state = app(InvestorContext::class)->state($case);
$context = ['revision' => $state['revision'], 'dependency_hash' => $state['dependency_hash'] ?? null];
if ($previous && $previous['guide'] === $guide && $previous['instruments'] === $reference['instruments'] && $previous['note'] === $reference['note'] && $previous['context'] === $context) {
return ['saved' => true, 'unchanged' => true, 'revision' => $previous['revision']];
}
$snapshot = ['revision' => (int) ($previous['revision'] ?? 0) + 1, 'guide' => $guide, 'instruments' => $reference['instruments'], 'note' => $reference['note'],
'context' => $context, 'scope' => 'dossier_reference', 'at' => now()->toIso8601String(), 'actor_id' => $actor->id];
$payload['investment_guides'][$slug] = $snapshot;
$case->version++;
DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'version' => $case->version, 'updated_at' => now()]);
$hash = hash('sha256', json_encode([(int) $v['version'], $slug, $reference], JSON_THROW_ON_ERROR));
$this->desk->event($case, $actor, self::EVENT, ['operation_key' => $v['operation_key'], 'request_hash' => $hash, 'snapshot' => $snapshot]);
return ['saved' => true, 'replayed' => false, 'revision' => $snapshot['revision']];
});
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/** Idempotent self-service commands, serialized by the caller's existing user row lock. */
final class InvestmentProfileCommands
{
private const SOURCE = 'investment-workspace-command';
/**
* @param array<string,mixed> $command
* @return array{version:int,id?:string,country?:string}|null
*/
public function replay(User $actor, ?string $key, array $command): ?array
{
if ($key === null) {
return null;
}
$event = DB::table('audit_events')->where('source_system', self::SOURCE)->where('actor_user_id', $actor->id)->where('correlation_id', strtolower($key))->first();
if (! $event) {
return null;
}
$data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR);
abort_unless(is_array($data) && is_string($data['command_encrypted'] ?? null) && hash_equals(Crypt::decryptString($data['command_encrypted']), $this->canonical($command)), 409,
'Este pedido já foi utilizado com outros dados. Consulte o registo guardado antes de preparar outro pedido.');
$result = is_string($data['result_encrypted'] ?? null) ? json_decode(Crypt::decryptString($data['result_encrypted']), true, 512, JSON_THROW_ON_ERROR) : ($data['result'] ?? null);
if (! is_array($result) || ! is_int($result['version'] ?? null) || $result['version'] < 1
|| (array_key_exists('id', $result) && (! is_string($result['id']) || ! Str::isUuid($result['id'])))
|| (array_key_exists('country', $result) && (! is_string($result['country']) || preg_match('/^[A-Z]{2}$/D', $result['country']) !== 1))) {
throw new \LogicException('Stored workspace command result unavailable.');
}
$receipt = isset($result['id']) ? ['id' => $result['id'], 'version' => $result['version']] : ['version' => $result['version']];
if (isset($result['country'])) {
$receipt['country'] = $result['country'];
}
return $receipt;
}
/** @param array<string,mixed> $command
* @param array{version:int,id?:string,country?:string} $result */
public function record(User $actor, ?string $key, array $command, array $result): void
{
if ($key === null) {
return;
}
DB::table('audit_events')->insert(['id' => (string) Str::uuid(), 'source_system' => self::SOURCE, 'actor_user_id' => $actor->id,
'entity_type' => 'investment-profile-command', 'entity_id' => $result['id'] ?? null, 'action' => 'command_applied', 'correlation_id' => strtolower($key), 'data_classification' => 'restricted',
'after_data' => json_encode(['command_encrypted' => Crypt::encryptString($this->canonical($command)), ...(array_key_exists('country', $result) ? ['result_encrypted' => Crypt::encryptString(json_encode($result, JSON_THROW_ON_ERROR))] : ['result' => $result])], JSON_THROW_ON_ERROR)]);
}
/** @param array<string,mixed> $command */
private function canonical(array $command): string
{
if (is_array($command['payload'] ?? null)) {
ksort($command['payload']);
}
ksort($command);
return json_encode($command, JSON_THROW_ON_ERROR);
}
}
+2 -2
View File
@@ -176,9 +176,9 @@ final class InvestorPath
->whereNull('deleted_at')
->when(! $owner && ! $manager, fn ($q) => $q->where('assignee_user_id', $actor->id))
->get(['id', 'title', 'status', 'version_no', 'due_at', 'assignee_user_id']),
'services' => DB::table('desk_service_versions')->where('status', 'published')->orderByDesc('version')->get(['id', 'code', 'definition'])
->unique('code')->values()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title']]),
'services' => app(DeskServiceAvailability::class)->current()->get()->map(fn ($s) => ['id' => $s->id, 'title' => json_decode($s->definition, true)['title'], 'effective_from' => $s->effective_from ?? null]),
'tools' => ['simulator' => (int) $case->user_id === $actor->id ? '/simulador-fiscal?context='.$id.($case->simulation_id ? '&draft='.$case->simulation_id : '').($opportunity ? '&opportunity='.urlencode($opportunity) : '') : null,
'company' => '/investimentos/'.$id.'/forma-juridica',
'desk' => '/balcao-virtual/dossiers/'.$id.($opportunity ? '?opportunity='.urlencode($opportunity) : ''), 'guidance' => '/balcao-virtual/dossiers/'.$id.'/orientacao',
'library' => '/documentos?context='.$id, 'opportunities' => '/oportunidades?context='.$id, 'territory' => '/zonas?context='.$id, 'partners' => '/parcerias/painel?context='.$id,
'land' => ($facts['land_needed'] ?? 'unknown') !== 'no' && (int) $case->user_id === $actor->id ? ($landCase ? '/investidor/terra-duat/'.$landCase->id : '/investidor/terra-duat/novo?context='.$id.($opportunity ? '&opportunity='.urlencode($opportunity) : '')) : null]];
@@ -0,0 +1,76 @@
<?php
namespace App\Services\Malware;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use stdClass;
/** Attempt ownership in the existing audit ledger; callers hold the document row lock. */
final class DocumentScanAttempts
{
private const SOURCE = 'document-malware-worker';
public function owns(stdClass $document, string $run, int $delivery, ?int $expectedAttempt = null): bool
{
$attempt = (int) $document->malware_scan_attempts;
if ($expectedAttempt !== null && $attempt !== $expectedAttempt) {
return false;
}
$events = DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'document-scan')->where('entity_id', $document->id)
->where('action', 'scan_started')->whereRaw("after_data->>'attempt' = ?", [(string) $attempt])->get(['after_data']);
if ($events->count() !== 1) {
return false;
}
$lease = json_decode($events->first()->after_data, true, 512, JSON_THROW_ON_ERROR);
return is_array($lease) && ($lease['run_id'] ?? null) === $run && ($lease['delivery_attempt'] ?? null) === $delivery && ($lease['attempt'] ?? null) === $attempt;
}
public function start(stdClass $document, string $run, int $delivery): int
{
$attempt = (int) $document->malware_scan_attempts + 1;
DB::table('audit_events')->insert(['id' => (string) Str::uuid(), 'source_system' => self::SOURCE, 'entity_type' => 'document-scan', 'entity_id' => $document->id,
'actor_user_id' => null, 'action' => 'scan_started', 'correlation_id' => $run, 'data_classification' => 'restricted',
'after_data' => json_encode(['run_id' => $run, 'delivery_attempt' => $delivery, 'attempt' => $attempt, 'file_sha256' => trim($document->sha256), 'file_size_bytes' => $document->size_bytes ?? null], JSON_THROW_ON_ERROR)]);
return $attempt;
}
/** @return array{sha256:string,size_bytes:int|null}|null */
public function descriptor(stdClass $document, string $run, int $delivery): ?array
{
if (! $this->owns($document, $run, $delivery)) {
return null;
}
$event = DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'document-scan')->where('entity_id', $document->id)
->where('action', 'scan_started')->whereRaw("after_data->>'attempt' = ?", [(string) $document->malware_scan_attempts])->first(['after_data']);
$data = $event ? json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR) : null;
if (! is_array($data) || ! is_string($data['file_sha256'] ?? null) || preg_match('/^[a-f0-9]{64}$/D', $data['file_sha256']) !== 1
|| ! array_key_exists('file_size_bytes', $data) || ($data['file_size_bytes'] !== null && ! is_int($data['file_size_bytes']))) {
return null;
}
return ['sha256' => $data['file_sha256'], 'size_bytes' => $data['file_size_bytes']];
}
public function completed(string $documentId, string $run): bool
{
return DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'document-scan')->where('entity_id', $documentId)
->where('action', 'scan_result_recorded')->where('correlation_id', $run)->whereRaw("after_data->>'retryable_failure' = 'false'")->exists();
}
public function finish(stdClass $document, string $run, int $delivery, string $scanId, bool $retryableFailure): void
{
DB::table('audit_events')->insert(['id' => (string) Str::uuid(), 'source_system' => self::SOURCE, 'entity_type' => 'document-scan', 'entity_id' => $document->id,
'actor_user_id' => null, 'action' => 'scan_result_recorded', 'correlation_id' => $run, 'data_classification' => 'restricted',
'after_data' => json_encode(['run_id' => $run, 'delivery_attempt' => $delivery, 'attempt' => (int) $document->malware_scan_attempts, 'scan_id' => $scanId, 'retryable_failure' => $retryableFailure], JSON_THROW_ON_ERROR)]);
}
public function discarded(stdClass $document, string $run, int $delivery, int $expectedAttempt): void
{
DB::table('audit_events')->insert(['id' => (string) Str::uuid(), 'source_system' => self::SOURCE, 'entity_type' => 'document-scan', 'entity_id' => $document->id,
'actor_user_id' => null, 'action' => 'scan_result_discarded', 'correlation_id' => $run, 'data_classification' => 'restricted',
'after_data' => json_encode(['run_id' => $run, 'delivery_attempt' => $delivery, 'attempt' => $expectedAttempt, 'current_attempt' => (int) $document->malware_scan_attempts], JSON_THROW_ON_ERROR)]);
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Services\Malware;
use App\Jobs\ScanDocumentForMalware;
use Closure;
use Illuminate\Bus\UniqueLock;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Foundation\Queue\InteractsWithUniqueJobs;
use Illuminate\Support\Facades\Bus;
/** Observe the existing unique-job lease without claiming that a worker ran. */
final class DocumentScanDelivery
{
use InteractsWithUniqueJobs;
/** @param Closure():bool|null $prepare */
public function dispatch(ScanDocumentForMalware $job, ?Closure $prepare = null): string
{
$lock = new UniqueLock(app(Repository::class));
if (! $lock->acquire($job)) {
return 'duplicate';
}
$attempted = false;
$contextAdded = false;
try {
if ($prepare !== null && ! $prepare()) {
return 'stale';
}
$this->addUniqueJobInformationToContext($job);
$contextAdded = true;
$attempted = true;
Bus::dispatch($job);
return 'accepted';
} finally {
if ($contextAdded) {
$this->removeUniqueJobInformationFromContext($job);
}
// A push error may follow acceptance. Keep its lease until the worker or expiry.
if (! $attempted) {
$lock->release($job);
}
}
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Services\Malware;
use App\Jobs\ScanDocumentForMalware;
use App\Models\User;
use App\Services\DocumentManagementAccess;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
final class DocumentScanRetry
{
/** @return array{outcome:string,reference:string} */
public function request(User $actor, string $id): array
{
$job = new ScanDocumentForMalware($id);
$outcome = 'deferred';
DB::transaction(function () use ($actor, $id, $job, &$outcome): void {
$document = app(DocumentManagementAccess::class)->documents($actor)->where('id', $id)->lockForUpdate()->first();
abort_unless($document && $document->status !== 'deleted' && $document->deleted_at === null, 404);
abort_unless(in_array($document->malware_scan_status, ['pending', 'inconclusive', 'failed'], true), 422,
'Só é possível repetir uma análise pendente, inconclusiva ou falhada. Para substituir um ficheiro, carregue uma nova versão.');
DB::afterCommit(function () use ($actor, $id, $job, $document, &$outcome): void {
$prepared = false;
try {
$outcome = app(DocumentScanDelivery::class)->dispatch($job, function () use ($actor, $id, $job, $document, &$prepared): bool {
$ready = DB::transaction(function () use ($actor, $id, $job, $document): bool {
$currentActor = $actor->fresh();
if (! $currentActor) {
return false;
}
$access = app(DocumentManagementAccess::class);
if (! $access->full($currentActor) && ! $access->limited($currentActor) && ! $access->editorial($currentActor)) {
return false;
}
$current = $access->documents($currentActor)->where('id', $id)->lockForUpdate()->first();
if (! $current || $current->status === 'deleted' || $current->deleted_at !== null
|| $current->sha256 !== $document->sha256 || $current->malware_scan_attempts !== $document->malware_scan_attempts
|| $current->malware_scan_status !== $document->malware_scan_status || $current->updated_at !== $document->updated_at) {
return false;
}
DB::table('documents')->where('id', $id)->update(['status' => 'quarantined', 'malware_scan_status' => 'pending', 'malware_failure_reason' => null, 'updated_at' => now()]);
$access->audit($currentActor, 'scan_retry_requested', ['document_id' => $id, 'reference' => $job->runIdentifier(), 'previous_scan_status' => $current->malware_scan_status]);
return true;
});
$prepared = $ready;
return $ready;
});
} catch (Throwable) {
$outcome = 'unconfirmed';
try {
if ($prepared) {
DB::transaction(function () use ($id, $document): void {
$current = DB::table('documents')->where('id', $id)->lockForUpdate()->first();
if ($current && $current->deleted_at === null && $current->status !== 'deleted'
&& $current->sha256 === $document->sha256 && $current->malware_scan_attempts === $document->malware_scan_attempts && $current->malware_scan_status === 'pending') {
DB::table('documents')->where('id', $id)->update(['status' => 'quarantined', 'malware_scan_status' => 'failed', 'malware_failure_reason' => 'queue_dispatch_failed', 'updated_at' => now()]);
}
});
}
} catch (Throwable) {
// An uncertain write remains private and needs reconciliation.
}
try {
Log::warning('Document scan retry dispatch requires verification.', ['reference' => $job->runIdentifier(), 'error_code' => 'scan_dispatch_unconfirmed']);
} catch (Throwable) {
}
}
});
});
return ['outcome' => $outcome, 'reference' => $job->runIdentifier()];
}
}
+7 -11
View File
@@ -2,7 +2,6 @@
namespace App\Services;
use App\Jobs\ScanDocumentForMalware;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
@@ -25,8 +24,9 @@ class OpportunityImageProcessor
$data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'reason' => 'required|string|min:20|max:1000'])->validate();
$stored = [];
$documents = [];
$scanContext = null;
try {
DB::transaction(function () use ($actor, $opportunityId, $id, $data, &$stored, &$documents) {
DB::transaction(function () use ($actor, $opportunityId, $id, $data, &$stored, &$documents, &$scanContext) {
$o = Opportunity::withTrashed()->whereKey($opportunityId)->lockForUpdate()->firstOrFail();
app(PortalProfileAccess::class)->authorize($actor, 'edit', $o);
abort_if($o->trashed() || $o->status === 'archived', 409, 'Restaure antes de processar.');
@@ -65,6 +65,7 @@ class OpportunityImageProcessor
}
if ($reusable) {
$documents = $retry;
$scanContext = app(OpportunityTechnicalIncidents::class)->scanContext($actor, $o);
return; // Retry scans of the same streams, without new files, versions or audit decisions.
}
@@ -87,8 +88,8 @@ class OpportunityImageProcessor
abort_unless($encoded && is_string($webp), 500, 'Falha ao gerar WebP.');
$documentId = (string) Str::uuid();
$key = 'opportunity-resources/'.$documentId;
abort_unless($disk->put($key, Crypt::encryptString($webp)) === true, 500, 'Falha ao guardar derivada cifrada.');
$stored[] = $key;
abort_unless($disk->put($key, Crypt::encryptString($webp)) === true, 500, 'Falha ao guardar derivada cifrada.');
$documents[] = $documentId;
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'opportunity-image-derivatives', 'file_name' => 'image-'.$width.'.webp', 'media_type' => 'image/webp', 'storage_key' => $key, 'storage_encryption' => 'laravel', 'sha256' => hash('sha256', $webp), 'status' => 'quarantined', 'data_classification' => 'restricted', 'size_bytes' => strlen($webp), 'uploaded_by' => $actor->id]);
$derivatives[] = ['document_id' => $documentId, 'width' => $w, 'height' => $h];
@@ -104,20 +105,15 @@ class OpportunityImageProcessor
DB::table('audit_events')->insert(['source_system' => 'opportunity-resources', 'entity_type' => 'images', 'action' => 'derivatives_prepared', 'actor_user_id' => $actor->id, 'data_classification' => 'restricted', 'before_data' => json_encode(['snapshot' => Crypt::encryptString(json_encode($image, JSON_THROW_ON_ERROR))]), 'after_data' => json_encode(['opportunity_id' => $opportunityId, 'resource_id' => $id, 'version' => $image->version + 1, 'reason' => Crypt::encryptString($data['reason']), 'snapshot' => Crypt::encryptString(json_encode(DB::table('opportunity_images')->where('id', $id)->first(), JSON_THROW_ON_ERROR))])]);
$o->management_version++;
$o->save();
$scanContext = app(OpportunityTechnicalIncidents::class)->scanContext($actor, $o);
});
} catch (\Throwable $error) {
foreach ($stored as $key) {
Storage::disk('local')->delete($key);
}
app(OpportunityResourceFileCleanup::class)->afterFailure($stored, $error);
throw $error;
}
// Each new byte stream receives its own scan; no automatic clean result inherited from the original.
foreach ($documents as $documentId) {
try {
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
} catch (\Throwable) {
DB::table('documents')->where('id', $documentId)->update(['malware_scan_status' => 'failed', 'malware_failure_reason' => 'queue_dispatch_failed']);
}
app(OpportunityScanDispatch::class)->dispatch($documentId, $scanContext);
}
}
}
@@ -326,6 +326,7 @@ class OpportunityProfileWorkflow
if (isset($p['business_transaction'])) {
abort_unless(in_array($p['business_transaction']['due_diligence_status'], ['data_room_ready', 'in_progress', 'completed', 'exceptions'], true), 422, 'Data room mínimo não preparado.');
}
$previousMaturity = $context['publication']['maturity'] ?? null;
if (isset($context['publication'])) {
$context['publication_history'][] = [...$context['publication'],
'public_maturity_before_republication' => ['level' => $record->maturity_level, 'label' => $record->maturity_label,
@@ -350,6 +351,17 @@ class OpportunityProfileWorkflow
$record->maturity_label = $maturity['maturity_label'];
$context['publication']['maturity'] = ['catalogue_version' => $maturity['maturity_catalogue_version'],
'stage' => $maturity['maturity_stage'], 'level' => $maturity['maturity_level'], 'label' => $maturity['maturity_label']];
} elseif (array_key_exists('maturity_stage', $p)) {
// An explicitly cleared optional answer must not retain an old
// public number or make that number look like a legacy level.
$record->maturity_level = null;
$record->maturity_label = null;
$context['publication']['maturity'] = ['catalogue_version' => OpportunityMaturity::VERSION,
'stage' => null, 'level' => null, 'label' => null];
} elseif ($previousMaturity !== null) {
// No answer was supplied: keep the version with the retained
// public values, including records prepared by older editors.
$context['publication']['maturity'] = $previousMaturity;
}
$locations = array_values(array_filter($snapshot['resources']['locations'], fn ($location) => $location['payload']['is_primary'] ?? false));
$location = $locations[0]; // The contract requires exactly one primary location.
@@ -0,0 +1,65 @@
<?php
namespace App\Services;
use Illuminate\Database\DetectsLostConnections;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;
/** Never remove bytes whose database outcome or reference status is uncertain. */
final class OpportunityResourceFileCleanup
{
use DetectsLostConnections;
/** @param list<string> $keys */
public function afterFailure(array $keys, Throwable $failure): void
{
try {
$uncertain = $this->causedByLostConnection($failure);
} catch (Throwable) {
$uncertain = true;
}
if ($uncertain) {
$this->unconfirmed();
return;
}
foreach (array_unique($keys) as $key) {
if (preg_match('/^opportunity-resources\/[a-f0-9-]{36}$/D', $key) !== 1) {
continue;
}
try {
$referenced = DB::transaction(function () use ($key) {
$visibility = DB::selectOne("SELECT row_security_active('documents'::regclass) AS active");
if ($visibility?->active !== false) {
return null;
}
return DB::table('documents')->where('storage_key', $key)->exists();
});
if ($referenced !== false) {
if ($referenced === null) {
$this->unconfirmed();
}
continue;
}
if (Storage::disk('local')->delete($key) !== true) {
$this->unconfirmed();
}
} catch (Throwable) {
$this->unconfirmed();
}
}
}
private function unconfirmed(): void
{
try {
Log::warning('Opportunity resource cleanup requires verification.', ['error_code' => 'resource_cleanup_unconfirmed']);
} catch (Throwable) { /* Preserve the original operation failure and retained bytes. */
}
}
}
+14 -10
View File
@@ -2,7 +2,6 @@
namespace App\Services;
use App\Jobs\ScanDocumentForMalware;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Http\Exceptions\HttpResponseException;
@@ -69,13 +68,14 @@ class OpportunityResources
}
$stored = null;
$documentId = null;
$scanContext = null;
if ($file) {
[$stored, $documentId] = $this->store($kind, $file);
} elseif (! $id && $kind !== 'locations') {
throw ValidationException::withMessages(['file' => 'Selecione o ficheiro original.']);
}
try {
$result = DB::transaction(function () use ($actor, $opportunityId, $kind, $table, $data, $payload, $file, $id, $stored, $documentId) {
$result = DB::transaction(function () use ($actor, $opportunityId, $kind, $table, $data, $payload, $file, $id, $stored, $documentId, &$scanContext) {
$opportunity = $this->lock($opportunityId, $data['etag']);
app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity);
$record = $id ? DB::table($table)->where('id', $id)->where('opportunity_id', $opportunityId)->lockForUpdate()->first() : null;
@@ -136,22 +136,21 @@ class OpportunityResources
$this->audit($actor, $kind, $record, $after, $data['reason'], 'saved');
$opportunity->management_version++;
$opportunity->save();
if ($documentId !== null) {
$scanContext = app(OpportunityTechnicalIncidents::class)->scanContext($actor, $opportunity);
}
return $after;
});
} catch (\Throwable $error) {
if ($stored) {
Storage::disk('local')->delete($stored);
app(OpportunityResourceFileCleanup::class)->afterFailure([$stored], $error);
}
throw $error;
}
if ($documentId) {
try {
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
} catch (\Throwable) {
DB::table('documents')->where('id', $documentId)->where('malware_scan_status', '!=', 'clean')->update(['status' => 'quarantined', 'malware_scan_status' => 'failed', 'malware_failure_reason' => 'queue_dispatch_failed', 'updated_at' => now()]);
}
app(OpportunityScanDispatch::class)->dispatch($documentId, $scanContext);
}
return $result;
@@ -286,8 +285,13 @@ class OpportunityResources
}
}
$id = (string) Str::uuid();
$key = $file->storeAs('opportunity-resources', $id, 'local');
abort_unless(is_string($key), 500, 'Falha ao guardar original privado.');
try {
$key = $file->storeAs('opportunity-resources', $id, 'local');
abort_unless(is_string($key), 500, 'Falha ao guardar original privado.');
} catch (\Throwable $error) {
app(OpportunityResourceFileCleanup::class)->afterFailure(['opportunity-resources/'.$id], $error);
throw $error;
}
return [$key, $id];
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Services;
use App\Jobs\ScanDocumentForMalware;
use App\Services\Malware\DocumentScanDelivery;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/** A saved resource remains saved when its post-commit scan dispatch cannot be confirmed. */
final class OpportunityScanDispatch
{
/** @param array<string,mixed>|null $context */
public function dispatch(string $documentId, ?array $context): void
{
$job = new ScanDocumentForMalware($documentId, $context);
$dispatch = function () use ($documentId, $context, $job): void {
try {
// Existing uniqueness, retries and scanner are retained.
app(DocumentScanDelivery::class)->dispatch($job);
} catch (Throwable) {
try {
DB::transaction(function () use ($documentId): void {
$document = DB::table('documents')->where('id', $documentId)->lockForUpdate()->first();
if ($document && $document->deleted_at === null && $document->status !== 'deleted' && $document->malware_scan_status === 'pending') {
DB::table('documents')->where('id', $documentId)->update(['status' => 'quarantined', 'malware_scan_status' => 'failed', 'malware_failure_reason' => 'queue_dispatch_failed', 'updated_at' => now()]);
}
});
} catch (Throwable) {
try {
Log::warning('Document scan dispatch state requires verification.', ['reference' => $job->runIdentifier(), 'error_code' => 'scan_dispatch_unconfirmed']);
} catch (Throwable) {
}
}
if ($context !== null) {
app(OpportunityTechnicalIncidents::class)->recordScan($context, $job->runIdentifier(), 'scan_dispatch_unconfirmed');
}
}
};
try {
DB::afterCommit($dispatch);
} catch (Throwable) {
// Registration itself failed: never escape after a successful resource commit.
if ($context !== null) {
app(OpportunityTechnicalIncidents::class)->recordScan($context, $job->runIdentifier(), 'scan_dispatch_unconfirmed');
}
}
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Services;
use App\Exceptions\OpportunityWorkflowUnavailable;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Throwable;
/** Observe an already-authorized operation after its transaction unwinds. Never retry it. */
final class OpportunityTechnicalExecution
{
public function __construct(private OpportunityTechnicalIncidents $incidents) {}
/** @template T
* @param array<string,mixed> $input
* @param callable():T $operation
* @return T */
public function run(User $actor, Opportunity $before, string $action, array $input, callable $operation): mixed
{
try {
return $operation();
} catch (Throwable $failure) {
if ($failure instanceof ValidationException || $failure instanceof AuthorizationException || $failure instanceof AuthenticationException
|| $failure instanceof ModelNotFoundException || ($failure instanceof HttpExceptionInterface && $failure->getStatusCode() < 500)
|| ($failure instanceof HttpResponseException && $failure->getResponse()->getStatusCode() < 500)) {
throw $failure;
}
$etag = is_string($input['etag'] ?? null) && preg_match('/^[a-f0-9]{64}$/D', $input['etag']) === 1 ? $input['etag'] : '';
$incident = $this->incidents->record($actor, $before, $action, $etag, $failure);
throw new OpportunityWorkflowUnavailable($incident['reference'], $incident['recorded'], $failure);
}
}
}
@@ -0,0 +1,134 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use PDOException;
use Throwable;
/** Minimal technical metadata, never submitted contents, documents or exception text. */
class OpportunityTechnicalIncidents
{
private const SOURCE = 'portal-opportunity-incident';
public const OPERATIONS = ['submit', 'publish', 'upload_document', 'upload_image', 'prepare_image', 'scan_document'];
public const SCAN_ERRORS = ['scan_dispatch_unconfirmed', 'scan_inconclusive', 'scan_worker_failed', 'scan_cleanup_unconfirmed'];
/** Captured inside the authorized resource transaction; contains no file or model contents.
* @return array<string,mixed>|null */
public function scanContext(User $actor, Opportunity $record): ?array
{
$attributes = $record->getAttributes();
if (! array_key_exists('organization_id', $attributes) || ! array_key_exists('workflow_context', $attributes)
|| ! in_array($record->opportunity_category, OpportunityManagement::CATEGORIES, true)) {
return null;
}
return ['opportunity_id' => $record->id, 'category' => $record->opportunity_category, 'organization_id' => $record->organization_id,
'observed_version' => (int) $record->management_version, 'observed_content_revision' => $record->workflow_context['content_revision'] ?? null,
'observed_state' => app(PortalProfileAccess::class)->state($record), 'reported_for_user_id' => $actor->id];
}
/** The queue executes as a system process; the initiating account is not impersonated.
* @param array<string,mixed> $context
* @return array{reference:string,recorded:bool} */
public function recordScan(array $context, string $reference, string $errorCode): array
{
try {
if (! Str::isUuid($reference) || ! in_array($errorCode, self::SCAN_ERRORS, true)
|| ! is_int($context['opportunity_id'] ?? null) || $context['opportunity_id'] <= 0
|| ! in_array($context['category'] ?? null, OpportunityManagement::CATEGORIES, true)
|| ! array_key_exists('organization_id', $context) || ($context['organization_id'] !== null && (! is_string($context['organization_id']) || ! Str::isUuid($context['organization_id'])))
|| ! is_int($context['reported_for_user_id'] ?? null) || $context['reported_for_user_id'] <= 0
|| ! is_int($context['observed_version'] ?? null) || $context['observed_version'] < 0 || ! is_string($context['observed_state'] ?? null)) {
throw new \LogicException('Invalid internal scan context.');
}
$metadata = ['reference' => $reference, 'opportunity_id' => $context['opportunity_id'], 'category' => $context['category'], 'organization_id' => $context['organization_id'],
'operation' => 'scan_document', 'attempted_etag' => '', 'observed_version' => $context['observed_version'], 'observed_content_revision' => $context['observed_content_revision'] ?? null,
'observed_state' => $context['observed_state'], 'error_code' => $errorCode, 'origin' => 'scan_system', 'reported_for_user_id' => $context['reported_for_user_id']];
$recorded = DB::transaction(function () use ($reference, $metadata) {
DB::table('audit_events')->insertOrIgnore(['id' => $reference, 'correlation_id' => $reference, 'source_system' => self::SOURCE, 'actor_user_id' => null,
'entity_type' => 'opportunity-technical-incident', 'entity_id' => $reference, 'action' => 'workflow_failed', 'data_classification' => 'restricted',
'after_data' => json_encode(['incident' => $metadata], JSON_THROW_ON_ERROR)]);
$event = DB::table('audit_events')->where('id', $reference)->first();
$old = $event ? json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['incident'] ?? null : null;
return $event && $event->source_system === self::SOURCE && $event->entity_type === 'opportunity-technical-incident' && $event->action === 'workflow_failed' && $event->actor_user_id === null
&& is_array($old) && ($old['origin'] ?? null) === 'scan_system' && ($old['operation'] ?? null) === 'scan_document'
&& ($old['opportunity_id'] ?? null) === $metadata['opportunity_id'] && ($old['organization_id'] ?? null) === $metadata['organization_id']
&& ($old['category'] ?? null) === $metadata['category'] && ($old['reported_for_user_id'] ?? null) === $metadata['reported_for_user_id'];
});
if (! $recorded) {
throw new \LogicException('Scan incident reference belongs to another event.');
}
return ['reference' => $reference, 'recorded' => true];
} catch (Throwable) {
try {
Log::warning('Opportunity scan incident could not be stored.', ['reference' => $reference, 'error_code' => in_array($errorCode, self::SCAN_ERRORS, true) ? $errorCode : 'scan_worker_failed']);
} catch (Throwable) { /* Preserve the original worker/dispatch outcome. */
}
return ['reference' => $reference, 'recorded' => false];
}
}
/** Called after the business transaction has unwound.
* @return array{reference:string,recorded:bool} */
public function record(User $actor, Opportunity $before, string $action, string $attemptedEtag, Throwable $failure): array
{
$reference = (string) Str::uuid();
$metadata = ['reference' => $reference, 'opportunity_id' => $before->id, 'category' => $before->opportunity_category, 'organization_id' => $before->organization_id, 'operation' => $action, 'attempted_etag' => $attemptedEtag,
'observed_version' => (int) $before->management_version, 'observed_content_revision' => $before->workflow_context['content_revision'] ?? null,
'observed_state' => app(PortalProfileAccess::class)->state($before),
'error_code' => ($failure instanceof QueryException || $failure instanceof PDOException) ? 'database_failure' : 'workflow_unavailable'];
try {
DB::transaction(fn () => DB::table('audit_events')->insert(['id' => $reference, 'correlation_id' => $reference, 'source_system' => self::SOURCE, 'actor_user_id' => $actor->id, 'entity_type' => 'opportunity-technical-incident', 'entity_id' => $reference,
'action' => 'workflow_failed', 'data_classification' => 'restricted', 'after_data' => json_encode(['incident' => $metadata], JSON_THROW_ON_ERROR)]));
return ['reference' => $reference, 'recorded' => true];
} catch (Throwable) {
// Database failure must not hide the original result or expose its message/SQL.
try {
Log::warning('Opportunity technical incident could not be stored.', ['reference' => $reference, 'error_code' => $metadata['error_code']]);
} catch (Throwable) { /* The response still gives the correlation reference. */
}
return ['reference' => $reference, 'recorded' => false];
}
}
/** Current access and actor ownership are both required, even after revocation.
* @return list<array{reference:string,operation:string,version:int,occurred_at:string,error_code:string,handling:array{version:int,status:string,updated_at:string|null}|null}> */
public function recent(User $actor, Opportunity $record): array
{
app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
$result = [];
foreach (DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'opportunity-technical-incident')->whereRaw("after_data->'incident'->>'opportunity_id' = ?", [(string) $record->id])
->whereRaw("COALESCE(after_data->'incident'->>'organization_id','') = ?", [$record->organization_id ?? ''])
->whereRaw("COALESCE(after_data->'incident'->>'category','') = ?", [$record->opportunity_category ?? ''])
->where(function ($query) use ($actor) {
$query->where('actor_user_id', $actor->id)->orWhere(function ($system) use ($actor) {
$system->whereNull('actor_user_id')->whereRaw("after_data->'incident'->>'origin' = 'scan_system'")->whereRaw("after_data->'incident'->>'operation' = 'scan_document'")
->whereRaw("after_data->'incident'->>'reported_for_user_id' = ?", [(string) $actor->id]);
});
})->where('action', 'workflow_failed')->orderByDesc('occurred_at')->orderByDesc('id')->limit(10)->get(['id', 'occurred_at', 'after_data']) as $event) {
$data = json_decode($event->after_data, true)['incident'] ?? null;
if (! is_array($data) || ($data['reference'] ?? null) !== $event->id || ! in_array($data['operation'] ?? null, self::OPERATIONS, true)
|| ! is_int($data['observed_version'] ?? null) || ! in_array($data['error_code'] ?? null, ['database_failure', 'workflow_unavailable', ...self::SCAN_ERRORS], true) || ! is_string($event->occurred_at)) {
continue;
}
$state = app(TechnicalIncidentHandling::class)->summaryFor($event->id);
$result[] = ['reference' => $event->id, 'operation' => $data['operation'], 'version' => $data['observed_version'], 'occurred_at' => $event->occurred_at, 'error_code' => $data['error_code'],
'handling' => $state === null ? null : ['version' => $state['version'], 'status' => $state['status'], 'updated_at' => $state['updated_at']]];
}
return $result;
}
}
@@ -0,0 +1,128 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/** Technical incident metadata only; never an operational-record ACL. */
final class OpportunityTechnicalSupport
{
private const NOTICE_SOURCE = 'portal-opportunity-support-notices';
public function installed(): bool
{
return app(TechnicalSupportProfileSchema::class)->inspect()['installed'];
}
public function allows(User $actor): bool
{
$access = app(PortalProfileAccess::class);
return $this->installed() && $access->available() && ! $access->has($actor, 'auditor') && $access->has($actor, 'technical_support');
}
/** @return array<string,mixed> */
public function queue(User $actor, string $status = 'all', bool $unread = false): array
{
abort_unless(in_array($status, ['all', 'open', 'in_progress', 'resolved'], true), 422);
$query = $this->query($actor);
if ($unread) {
$query->whereNotExists($this->seenQuery($actor));
}
if ($status !== 'all') {
$query->whereRaw("COALESCE((SELECT treatment.after_data->'handling'->>'status' FROM audit_events treatment WHERE treatment.source_system='portal-opportunity-support' AND treatment.entity_type='opportunity-technical-incident' AND treatment.entity_id=event.id AND treatment.action='incident_handled' ORDER BY (treatment.after_data->'handling'->'version') DESC NULLS FIRST,treatment.id DESC LIMIT 1),'open') = ?", [$status]);
}
return $query->select(['event.id', 'event.occurred_at', 'event.after_data'])->selectSub($this->seenQuery($actor)->select('notice.occurred_at')->limit(1), 'consulted_at')
->orderByDesc('event.occurred_at')->orderByDesc('event.id')->paginate(20)->withQueryString()->through(function ($event) {
$data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['incident'];
return ['reference' => $event->id, 'occurred_at' => $event->occurred_at, 'operation' => $data['operation'], 'version' => $data['observed_version'], 'error_code' => $data['error_code'],
'handling' => app(TechnicalIncidentHandling::class)->summaryFor($event->id), 'details_url' => '/suporte-tecnico/incidentes/'.$event->id, 'unread' => $event->consulted_at === null];
})->toArray();
}
/** Optional navigation projection: failure must not block unrelated portal work. */
public function unreadCount(User $actor): ?int
{
try {
return DB::transaction(fn () => $this->query($actor)->whereNotExists($this->seenQuery($actor))->count());
} catch (QueryException) {
return null;
}
}
private function seenQuery(User $actor): Builder
{
return DB::table('audit_events as notice')->where('notice.source_system', self::NOTICE_SOURCE)->where('notice.entity_type', 'opportunity-technical-incident')
->where('notice.action', 'incident_consulted')->where('notice.actor_user_id', $actor->id)->whereColumn('notice.entity_id', 'event.id');
}
/** @return array{unread:bool} */
public function notice(User $actor, string $reference): array
{
$root = $this->query($actor)->where('event.id', $reference)->select('event.id')->selectSub($this->seenQuery($actor)->select('notice.occurred_at')->limit(1), 'consulted_at')->first() ?? abort(404);
return ['unread' => $root->consulted_at === null];
}
/** A per-actor, commutative acknowledgement; never a treatment or business transition.
* @return array{reference:string,actor_id:int,read:bool,read_at:string} */
public function markConsulted(User $actor, string $reference): array
{
return DB::transaction(function () use ($actor, $reference) {
$this->root($actor, $reference, true);
$query = DB::table('audit_events')->where('source_system', self::NOTICE_SOURCE)->where('entity_type', 'opportunity-technical-incident')->where('entity_id', $reference)
->where('action', 'incident_consulted')->where('actor_user_id', $actor->id);
$seen = $query->first(['occurred_at']);
if ($seen === null) {
$id = (string) Str::uuid();
DB::table('audit_events')->insert(['id' => $id, 'correlation_id' => $reference, 'source_system' => self::NOTICE_SOURCE, 'entity_type' => 'opportunity-technical-incident', 'entity_id' => $reference,
'actor_user_id' => $actor->id, 'action' => 'incident_consulted', 'data_classification' => 'restricted', 'after_data' => json_encode(['consulted' => true], JSON_THROW_ON_ERROR)]);
$seen = $query->first(['occurred_at']);
}
$readAt = $seen?->occurred_at;
abort_unless(is_string($readAt), 503, 'A confirmação da consulta não ficou disponível; pode repetir esta acção.');
return ['reference' => $reference, 'actor_id' => $actor->id, 'read' => true, 'read_at' => $readAt];
});
}
public function root(User $actor, string $reference, bool $lock = false): \stdClass
{
$query = $this->query($actor)->where('event.id', $reference)->select(['event.id', 'event.occurred_at', 'event.after_data']);
if ($lock) {
$query->lock('FOR UPDATE OF event');
}
return $query->first() ?? abort(404);
}
private function query(User $actor): Builder
{
abort_unless($this->allows($actor), 403);
$grants = app(PortalProfileAccess::class)->grants($actor, 'technical_support');
return DB::table('audit_events as event')->join('opportunities as opportunity', DB::raw('opportunity.id::text'), '=', DB::raw("event.after_data->'incident'->>'opportunity_id'"))
->where('event.source_system', 'portal-opportunity-incident')->where('event.entity_type', 'opportunity-technical-incident')->where('event.action', 'workflow_failed')->whereNull('opportunity.deleted_at')
->whereRaw("opportunity.opportunity_category = event.after_data->'incident'->>'category'")
->whereRaw("COALESCE(opportunity.organization_id::text,'') = COALESCE(event.after_data->'incident'->>'organization_id','')")
->where(function (Builder $scopes) use ($grants) {
$scopes->whereRaw('FALSE');
foreach ($grants as $grant) {
$scopes->orWhere(function (Builder $scope) use ($grant) {
$scope->whereIn('opportunity.opportunity_category', json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR));
if ($grant->organization_id !== null) {
$scope->where('opportunity.organization_id', $grant->organization_id);
}
});
}
});
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
/** Observe technical failures without changing the approved transition engine. */
final class OpportunityWorkflowExecution
{
public function __construct(private OpportunityProfileWorkflow $workflow, private OpportunityTechnicalExecution $execution) {}
/** @param array<string,mixed> $input */
public function transition(User $actor, int $id, array $input): Opportunity
{
$action = $input['action'] ?? null;
if (! in_array($action, ['submit', 'publish'], true)) {
return $this->workflow->transition($actor, $id, $input);
}
$record = Opportunity::withTrashed()->findOrFail($id);
app(PortalProfileAccess::class)->authorize($actor, 'view', $record);
if (! in_array($action, $this->workflow->actions($actor, $record), true)) {
return $this->workflow->transition($actor, $id, $input);
}
return $this->execution->run($actor, $record, $action, $input, fn () => $this->workflow->transition($actor, $id, $input));
}
}
+25 -3
View File
@@ -48,7 +48,7 @@ class OrganizationCountry
public function save(User $actor, string $id, array $data): array
{
$this->authorize($actor, $id, true);
$data = Validator::make($data, ['version' => 'required|integer|min:1',
$data = Validator::make($data, ['version' => 'required|integer|min:1', 'operation_key' => ['sometimes', 'required', 'uuid'],
'country' => ['required', 'string', app(CountryCatalogue::class)->rule()],
'source_reference' => 'required|string|min:5|max:2000'], [
'country.required' => 'Selecione o país de constituição da organização.',
@@ -57,9 +57,17 @@ class OrganizationCountry
])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
DB::table('users')->where('id', $actor->id)->lockForUpdate()->firstOrFail();
DB::table('memberships')->where('organization_id', $id)->where('user_id', $actor->id)->lockForUpdate()->get();
$this->authorize($actor, $id, true);
$row = DB::table('organizations')->where('id', $id)->lockForUpdate()->firstOrFail();
$this->authorize($actor, $id, true);
$commands = app(InvestmentProfileCommands::class);
$command = ['kind' => 'organization_country', 'target_id' => $id, 'version' => (int) $data['version'], 'payload' => ['country' => $data['country'], 'source_reference' => $data['source_reference']]];
$replay = $commands->replay($actor, $data['operation_key'] ?? null, $command);
if ($replay !== null) {
return ['version' => $replay['version'], 'country' => $data['country']];
}
abort_unless((int) $row->version_no === (int) $data['version'], 409, 'Organização alterada. Recarregue.');
$provenance = ['kind' => 'representative_declaration', 'reference' => $data['source_reference'],
'actor_id' => $actor->id, 'at' => now()->toIso8601String(), 'source_system' => $row->source_system,
@@ -72,6 +80,8 @@ class OrganizationCountry
'after_data' => json_encode(['organization_id' => $id, 'version' => $row->version_no + 1,
'snapshot' => Crypt::encryptString(json_encode(['country' => $data['country'], 'provenance' => $provenance], JSON_THROW_ON_ERROR))])]);
$commands->record($actor, $data['operation_key'] ?? null, $command, ['version' => $row->version_no + 1]);
return ['version' => $row->version_no + 1, 'country' => $data['country']];
});
}
@@ -79,11 +89,23 @@ class OrganizationCountry
/** @return array<string, mixed> */
public function snapshot(User $actor, string $id): array
{
$snapshot = $this->readableSnapshot($actor, $id);
abort_unless($snapshot['country'] !== null, 422, 'País de constituição não informado no cadastro mestre.');
return $snapshot;
}
/** Caller owns a transaction; authorize again after waiting for the source lock.
* @return array<string, mixed> */
public function readableSnapshot(User $actor, string $id): array
{
$this->authorize($actor, $id);
DB::table('memberships')->where('organization_id', $id)->where('user_id', $actor->id)->lockForUpdate()->get();
$this->authorize($actor, $id);
$org = DB::table('organizations')->where('id', $id)->lockForUpdate()->firstOrFail();
abort_unless($org->incorporation_country_code !== null, 422, 'País de constituição não informado no cadastro mestre.');
$this->authorize($actor, $id);
return ['organization_id' => $id, 'organization_version' => $org->version_no, 'country' => $org->incorporation_country_code,
return ['organization_id' => $org->id, 'organization_version' => $org->version_no, 'country' => $org->incorporation_country_code,
'provenance' => json_decode($org->incorporation_country_provenance, true), 'reused_at' => now()->toIso8601String(), 'reused_by' => $actor->id];
}
}
@@ -0,0 +1,141 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use stdClass;
class PartnershipAgreementHistory
{
/** @var array<int,string|null> */
private array $people = [];
public function __construct(private PartnershipAgreementProjection $projection) {}
/** @return array<string,mixed> */
public function page(User $actor, int $id, ?string $before = null): array
{
$context = app(PartnershipExchange::class)->room($actor, $id);
$boundary = $before === null ? null : $this->decode($before, $actor, $context);
$events = DB::table('partnership_events')->where('partnership_id', $context['partnership']->id)->where('event_type', 'partnership_agreement.changed.v1')
->whereRaw("payload->'payload'->>'room_id' = ?", [(string) $id])
->when($boundary, fn ($q) => $q->where('aggregate_version', '<', $boundary))->orderByDesc('aggregate_version')->limit(11)->get(['aggregate_version', 'actor_id', 'occurred_at', 'payload']);
$page = $events->take(10);
$base = '/parcerias/salas/'.$id.'/historico';
$last = $page->last();
$next = $events->count() > 10 && $last ? $base.'?before='.rawurlencode(Crypt::encryptString(json_encode(['purpose' => 'agreement', 'actor' => $actor->id, 'room' => $id, 'interest' => (int) $context['interest']->id, 'partnership' => (int) $context['partnership']->id, 'before' => (int) $last->aggregate_version], JSON_THROW_ON_ERROR))) : null;
return ['room_id' => $id, 'entries' => $page->map(fn ($event) => $this->entry($event, $actor, $context))->all(), 'first_url' => $base, 'next_url' => $next, 'older_page' => $before !== null, 'room_href' => '/parcerias/salas/'.$id, 'dashboard_href' => '/parcerias/painel'];
}
/** @param array{partnership:stdClass,interest:stdClass,room:stdClass} $context */
private function decode(string $token, User $actor, array $context): int
{
try {
$value = json_decode(Crypt::decryptString($token), true, 512, JSON_THROW_ON_ERROR);
} catch (DecryptException|\JsonException) {
throw ValidationException::withMessages(['before' => 'A referência da página não é válida. Volte ao início do histórico.']);
}
if (! is_array($value) || ($value['purpose'] ?? null) !== 'agreement' || ($value['actor'] ?? null) !== $actor->id || ($value['room'] ?? null) !== (int) $context['room']->id || ($value['interest'] ?? null) !== (int) $context['interest']->id || ($value['partnership'] ?? null) !== (int) $context['partnership']->id || ! $this->positive($value['before'] ?? null)) {
throw ValidationException::withMessages(['before' => 'A referência da página não pertence a esta sala e conta. Volte ao início do histórico.']);
}
return $value['before'];
}
/** @param array{partnership:stdClass,interest:stdClass,room:stdClass} $context
* @return array<string,mixed>
*/
private function entry(stdClass $event, User $actor, array $context): array
{
$envelope = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR);
$data = is_array($envelope) && is_array($envelope['payload'] ?? null) ? $envelope['payload'] : [];
$scope = $data['_notification_context'] ?? null;
$proponent = is_array($scope) ? $this->scope($scope['partnership'] ?? null) : null;
$candidate = is_array($scope) ? $this->scope($scope['interest'] ?? null) : null;
$known = is_array($scope) && ($scope['version'] ?? null) === 1 && ($scope['room_id'] ?? null) === (int) $context['room']->id && $proponent !== null && $candidate !== null && ($proponent['id'] ?? null) === (int) $context['partnership']->id && ($candidate['id'] ?? null) === (int) $context['interest']->id;
$exchange = app(PartnershipExchange::class);
$visible = $known && ($exchange->owns($actor, (object) $proponent, false) || $exchange->owns($actor, (object) $candidate, false));
$version = $this->positive($data['version'] ?? null) ? $data['version'] : null;
$unavailable = ['availability' => $known ? 'restricted' : 'scope_unknown'];
return ['version' => $version, 'restricted' => ! $visible,
'action' => $visible && in_array($data['action'] ?? null, ['record', 'confirm', 'start', 'complete', 'cancel', 'renew'], true) ? $data['action'] : null,
'actor_name' => $visible ? $this->person((int) $event->actor_id) : null, 'occurred_at' => $visible ? $event->occurred_at : null,
'reason' => $visible && is_string($data['reason'] ?? null) ? $data['reason'] : null,
'confirmation_party' => $visible && in_array($data['confirmation_party'] ?? null, ['proponent', 'candidate'], true) ? $data['confirmation_party'] : null,
'partnership_version' => $visible && $this->positive($data['partnership_version'] ?? null) ? $data['partnership_version'] : null,
'interest_version' => $visible && $this->positive($data['interest_version'] ?? null) ? $data['interest_version'] : null,
'previous' => $visible ? $this->snapshot($data['previous'] ?? null, $actor, (int) $context['room']->id) : $unavailable,
'after' => $visible ? $this->snapshot($data['applied'] ?? null, $actor, (int) $context['room']->id) : $unavailable];
}
/** @return array<string,mixed> */
private function snapshot(mixed $value, User $actor, int $id): array
{
if ($value === null) {
return ['availability' => 'not_recorded'];
}
if (! is_array($value) || ($value['id'] ?? null) !== $id || ! $this->positive($value['version'] ?? null) || ! is_string($value['status'] ?? null)) {
return ['availability' => 'scope_unknown'];
}
$view = ['availability' => 'available', 'version' => $value['version'], 'status' => $value['status'], 'instrument_availability' => 'not_recorded'];
if (! array_key_exists('agreement', $value)) {
return $view;
}
$instrument = $value['agreement'];
if ($instrument === null) {
return $view;
}
if (! is_array($instrument)) {
return [...$view, 'instrument_availability' => 'malformed'];
}
$scope = $instrument['recorded_context'] ?? null;
$proponent = is_array($scope) ? $this->scope($scope['proponent'] ?? null) : null;
$candidate = is_array($scope) ? $this->scope($scope['candidate'] ?? null) : null;
if ($proponent === null || $candidate === null || ! $this->positive($scope['partnership_version'] ?? null) || ! $this->positive($scope['interest_version'] ?? null)) {
return [...$view, 'instrument_availability' => 'scope_unknown'];
}
$exchange = app(PartnershipExchange::class);
if (! $exchange->owns($actor, (object) $proponent, false) && ! $exchange->owns($actor, (object) $candidate, false)) {
return [...$view, 'instrument_availability' => 'restricted'];
}
$fields = $this->projection->fields($instrument, true);
return [...$view, 'instrument_availability' => 'available', 'instrument' => $fields, 'instrument_partnership_version' => $scope['partnership_version'], 'instrument_interest_version' => $scope['interest_version']];
}
/** @return array{id?:int,user_id:int,organization_id:string|null}|null */
private function scope(mixed $value): ?array
{
if (! is_array($value) || ! $this->positive($value['user_id'] ?? null) || ! array_key_exists('organization_id', $value) || ! ($value['organization_id'] === null || is_string($value['organization_id']) && Str::isUuid($value['organization_id']))) {
return null;
}
$scope = ['user_id' => $value['user_id'], 'organization_id' => $value['organization_id']];
if ($this->positive($value['id'] ?? null)) {
$scope['id'] = $value['id'];
}
return $scope;
}
/** @phpstan-assert-if-true positive-int $value */
private function positive(mixed $value): bool
{
return is_int($value) && $value > 0;
}
private function person(int $id): ?string
{
if (! array_key_exists($id, $this->people)) {
$this->people[$id] = DB::table('users')->where('id', $id)->value('name');
}
return $this->people[$id];
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use stdClass;
/** Formats an already-authorized instrument; callers retain their own access checks. */
class PartnershipAgreementProjection
{
/** @var array<int,string|null> */
private array $people = [];
public function current(?string $json): stdClass|string|null
{
$value = $json === null ? null : json_decode($json, false, 512, JSON_THROW_ON_ERROR);
if ($value === null) {
return null;
}
if (! $value instanceof stdClass) {
return 'unrecognized';
}
return (object) $this->fields((array) $value);
}
/** @param array<string,mixed> $instrument
* @return array<string,mixed>
*/
public function fields(array $instrument, bool $names = false): array
{
$fields = [];
foreach (['title', 'terms', 'legal_signature_status'] as $key) {
if (is_string($instrument[$key] ?? null)) {
$fields[$key] = $instrument[$key];
}
}
if (array_key_exists('confirmations', $instrument)) {
$fields['confirmations'] = $this->confirmations($instrument['confirmations'], $names);
}
return $fields;
}
/** @return array<string,mixed>|stdClass|string|null */
private function confirmations(mixed $value, bool $names): array|stdClass|string|null
{
if ($value === null) {
return null;
}
if ($value instanceof stdClass) {
$value = (array) $value;
}
if (! is_array($value) || $value !== [] && array_is_list($value)) {
return 'unrecognized';
}
$result = [];
foreach (['proponent', 'candidate'] as $party) {
if (! array_key_exists($party, $value)) {
continue;
}
$stamp = $value[$party] instanceof stdClass ? (array) $value[$party] : (is_array($value[$party]) ? $value[$party] : []);
$id = is_int($stamp['actor_id'] ?? null) && $stamp['actor_id'] > 0 ? $stamp['actor_id'] : null;
$result[$party] = ['actor_id' => $id, 'date' => is_string($stamp['date'] ?? null) ? $stamp['date'] : null, 'reason' => is_string($stamp['reason'] ?? null) ? $stamp['reason'] : null];
if ($names) {
$result[$party]['actor_name'] = $id ? $this->person($id) : null;
}
}
if (array_diff(array_keys($value), ['proponent', 'candidate']) !== []) {
$result['_unrecognized_references'] = true;
}
return $result === [] ? (object) [] : $result;
}
private function person(int $id): ?string
{
if (! array_key_exists($id, $this->people)) {
$this->people[$id] = DB::table('users')->where('id', $id)->value('name');
}
return $this->people[$id];
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use stdClass;
class PartnershipAgreements
{
private const ACTIONS = ['record', 'confirm', 'start', 'complete', 'cancel', 'renew'];
/** @param array{partnership:stdClass,interest:stdClass,room:stdClass} $context */
public function party(User $actor, array $context): ?string
{
$exchange = app(PartnershipExchange::class);
return $exchange->owns($actor, $context['partnership']) ? 'proponent' : ($exchange->owns($actor, $context['interest']) ? 'candidate' : null);
}
/** @param array{partnership:stdClass,interest:stdClass,room:stdClass} $context
* @return list<array{key:string,action:string,version:int,status:string,party:string|null}>
*/
public function commands(User $actor, array $context): array
{
if ($this->party($actor, $context) === null) {
return [];
}
$rows = DB::table('partnership_events')->where('partnership_id', $context['partnership']->id)->where('actor_id', $actor->id)->where('event_type', 'partnership_agreement.changed.v1')->whereRaw("payload->'payload'->>'room_id' = ?", [(string) $context['room']->id])->whereRaw("payload->'payload'->>'client_key' IS NOT NULL")->orderBy('aggregate_version')->selectRaw("payload->'payload'->>'client_key' AS key,payload->'payload'->>'action' AS action,payload->'payload'->'version' AS version,payload->'payload'->>'status' AS status,payload->'payload'->>'confirmation_party' AS party")->get();
$commands = [];
foreach ($rows as $row) {
$version = is_string($row->version) ? json_decode($row->version, true, 512, JSON_THROW_ON_ERROR) : $row->version;
if (is_string($row->key) && Str::isUuid($row->key) && in_array($row->action, self::ACTIONS, true) && is_int($version) && $version > 0 && is_string($row->status)) {
$commands[] = ['key' => $row->key, 'action' => $row->action, 'version' => $version, 'status' => $row->status, 'party' => in_array($row->party, ['proponent', 'candidate'], true) ? $row->party : null];
}
}
return $commands;
}
/** @param array<string,mixed> $input
* @return array{recorded:bool,replayed:bool,version:int,status:string,saved_version:int}
*/
public function act(User $actor, int $id, array $input): array
{
$data = Validator::make($input, ['client_key' => 'nullable|uuid', 'version' => 'required|integer|min:1', 'partnership_version' => 'nullable|integer|min:1', 'interest_version' => 'nullable|integer|min:1', 'action' => 'required|in:'.implode(',', self::ACTIONS), 'title' => 'required_if:action,record|nullable|string|max:200', 'terms' => 'required_if:action,record|nullable|string|max:20000', 'reason' => 'required|string|max:2000'])->validate();
$key = is_string($data['client_key'] ?? null) ? strtolower($data['client_key']) : null;
unset($data['client_key']);
if ($data['action'] !== 'record') {
$data = Arr::except($data, ['title', 'terms']);
}
$hash = hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $id, $data, $key, $hash) {
$exchange = app(PartnershipExchange::class);
$context = $exchange->room($actor, $id);
$record = $exchange->find($context['partnership']->id, true);
$interest = DB::table('partnership_interests')->where('id', $context['interest']->id)->lockForUpdate()->first();
$room = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless($interest && $room && (int) $interest->partnership_id === (int) $record->id && (int) $room->interest_id === (int) $interest->id, 409, 'O contexto da sala foi alterado.');
$context = ['partnership' => $record, 'interest' => $interest, 'room' => $room];
$party = $this->party($actor, $context);
abort_unless($party !== null, 403, 'A representação actual não permite alterar o acordo.');
if ($key !== null) {
$event = DB::table('partnership_events')->where('partnership_id', $record->id)->where('actor_id', $actor->id)->where('event_type', 'partnership_agreement.changed.v1')->whereRaw("payload->'payload'->>'room_id' = ?", [(string) $id])->whereRaw("payload->'payload'->>'client_key' = ?", [$key])->selectRaw("payload->'payload'->>'command_hash' AS hash,payload->'payload'->'version' AS version")->first();
if ($event) {
abort_unless($event->hash === $hash, 409, 'Chave já utilizada com outro conteúdo.');
$version = is_string($event->version) ? json_decode($event->version, true, 512, JSON_THROW_ON_ERROR) : $event->version;
abort_unless(is_int($version) && $version > 0, 409, 'Versão anterior não identificada.');
return ['recorded' => true, 'replayed' => true, 'version' => (int) $room->version, 'status' => $room->status, 'saved_version' => $version];
}
}
abort_unless((int) $room->version === (int) $data['version'], 409, 'Acordo alterado. Compare antes de continuar.');
foreach (['partnership_version' => $record->version, 'interest_version' => $interest->version] as $field => $version) {
abort_if(isset($data[$field]) && (int) $data[$field] !== (int) $version, 409, 'A proposta ou candidatura foi alterada. Reveja o contexto antes de decidir.');
}
$agreement = $room->agreement ? json_decode($room->agreement, true, 512, JSON_THROW_ON_ERROR) : null;
abort_unless($agreement === null || is_array($agreement), 409, 'O instrumento guardado não tem uma estrutura reconhecida.');
$status = $room->status;
$before = $this->snapshot($room, $agreement);
$action = $data['action'];
if ($action === 'record') {
abort_unless(in_array($status, ['negotiation', 'agreement_recorded'], true), 409);
$previous = $agreement['previous_versions'] ?? [];
abort_unless(is_array($previous), 409, 'O histórico do instrumento não tem uma estrutura reconhecida.');
if ($agreement) {
$previous[] = Arr::except($agreement, ['previous_versions']);
}
$agreement = ['title' => $data['title'], 'terms' => $data['terms'], 'recorded_by' => $actor->id, 'recorded_at' => now()->toIso8601String(), 'confirmations' => [], 'parties' => [$record->user_id, $interest->user_id], 'legal_signature_status' => 'not_verified', 'previous_versions' => $previous,
'recorded_context' => ['partnership_version' => (int) $record->version, 'interest_version' => (int) $interest->version, 'proponent' => ['user_id' => (int) $record->user_id, 'organization_id' => $record->organization_id], 'candidate' => ['user_id' => (int) $interest->user_id, 'organization_id' => $interest->organization_id]]];
$status = 'agreement_recorded';
} elseif ($action === 'confirm') {
abort_unless($status === 'agreement_recorded' && $agreement, 409);
abort_unless(! isset($agreement['confirmations']) || is_array($agreement['confirmations']), 409, 'As confirmações guardadas não têm uma estrutura reconhecida.');
$agreement['confirmations'][$party] = ['actor_id' => $actor->id, 'date' => now()->toIso8601String(), 'reason' => $data['reason']];
if ($this->confirmation($agreement['confirmations']['proponent'] ?? null) && $this->confirmation($agreement['confirmations']['candidate'] ?? null)) {
$status = 'agreed';
}
} else {
$allowed = ['start' => ['agreed'], 'complete' => ['execution'], 'cancel' => ['negotiation', 'agreement_recorded', 'agreed', 'execution'], 'renew' => ['agreed', 'execution']];
abort_unless(in_array($status, $allowed[$action] ?? [], true), 409);
if ($action === 'complete') {
abort_if(DB::table('partnership_milestones')->where('room_id', $id)->where('status', '!=', 'completed')->exists(), 409, 'Existem marcos pendentes.');
}
$status = ['start' => 'execution', 'complete' => 'closed', 'cancel' => 'cancelled', 'renew' => 'negotiation'][$action];
if ($action === 'renew') {
abort_unless(is_array($agreement) && is_array($agreement['previous_versions'] ?? []), 409, 'O instrumento ou o seu histórico não tem uma estrutura reconhecida.');
$agreement['previous_versions'][] = Arr::except($agreement, ['previous_versions']);
$agreement['confirmations'] = [];
}
}
DB::table('partnership_rooms')->where('id', $id)->update(['agreement' => $agreement ? json_encode($agreement, JSON_THROW_ON_ERROR) : null, 'status' => $status, 'version' => $room->version + 1, 'updated_at' => now()]);
$saved = DB::table('partnership_rooms')->where('id', $id)->first();
$metadata = ['room_id' => $id, 'status' => $status, 'version' => (int) $saved->version, 'reason' => $data['reason'], 'action' => $action, 'confirmation_party' => $action === 'confirm' ? $party : null, 'partnership_version' => (int) $record->version, 'interest_version' => (int) $interest->version, 'previous' => $before, 'applied' => $this->snapshot($saved, $agreement)];
if ($key !== null) {
$metadata += ['client_key' => $key, 'command_hash' => $hash];
}
$exchange->event($record, $actor, 'partnership_agreement.changed.v1', $metadata, 'Acordo: '.$status, [$record->user_id, $interest->user_id]);
return ['recorded' => true, 'replayed' => false, 'version' => (int) $saved->version, 'status' => $status, 'saved_version' => (int) $saved->version];
});
}
private function confirmation(mixed $value): bool
{
return is_array($value) && is_int($value['actor_id'] ?? null) && $value['actor_id'] > 0;
}
/** @param array<string,mixed>|null $agreement
* @return array<string,mixed>
*/
private function snapshot(stdClass $room, ?array $agreement): array
{
return ['id' => (int) $room->id, 'version' => (int) $room->version, 'status' => $room->status, 'agreement' => $agreement];
}
}
+188
View File
@@ -0,0 +1,188 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use stdClass;
class PartnershipCandidacies
{
private const PAYLOAD = ['capacity', 'availability', 'contribution', 'conflicts', 'consent', 'sector_id', 'territory_id'];
public function accepting(stdClass $record): bool
{
return $record->status === 'published' && (! $record->deadline || $record->deadline >= today()->toDateString());
}
/** @return array<int,list<array{key:string,version:int,action:string}>> */
public function commands(User $actor, int $partnership): array
{
$commands = [];
$events = DB::table('partnership_events')->where('partnership_id', $partnership)->where('actor_id', $actor->id)
->whereIn('event_type', ['partnership_interest.submitted.v1', 'partnership_interest.changed.v1'])->whereRaw("payload->'payload'->>'client_key' IS NOT NULL")->get(['payload', 'event_type']);
foreach ($events as $event) {
$payload = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR)['payload'];
$commands[(int) $payload['interest_id']][] = ['key' => $payload['client_key'], 'version' => (int) $payload['interest_version'], 'action' => $event->event_type === 'partnership_interest.submitted.v1' ? 'submit' : $payload['action']];
}
return $commands;
}
/** @param array<string,mixed> $data */
public function submit(User $actor, int $id, array $data): stdClass
{
$data = Validator::make($data, ['client_key' => ['nullable', 'uuid'], 'interest_id' => ['nullable', 'integer', 'min:1'], 'partnership_version' => ['nullable', 'integer', 'min:1'], 'version' => ['nullable', 'integer', 'min:1'], 'organization_id' => ['nullable', 'uuid'], 'proposal' => ['required', 'string', 'max:15000'],
'payload' => ['array:'.implode(',', self::PAYLOAD)], 'payload.consent' => ['accepted'], 'payload.capacity' => ['nullable', 'string', 'max:5000'], 'payload.availability' => ['nullable', 'string', 'max:1000'], 'payload.contribution' => ['nullable', 'string', 'max:5000'], 'payload.conflicts' => ['nullable', 'string', 'max:5000'], 'payload.sector_id' => ['nullable', 'integer'], 'payload.territory_id' => ['nullable', 'uuid']])->validate();
if (isset($data['client_key'])) {
$data['client_key'] = strtolower($data['client_key']);
}
foreach (['interest_id', 'partnership_version', 'version'] as $integer) {
if (isset($data[$integer])) {
$data[$integer] = (int) $data[$integer];
}
}
$hash = $this->hash(Arr::except($data, 'client_key'));
return DB::transaction(function () use ($actor, $id, $data, $hash) {
$exchange = app(PartnershipExchange::class);
$record = $exchange->find($id, true);
$replay = $this->replay($actor, $record, 'partnership_interest.submitted.v1', $data['client_key'] ?? null, $hash);
if ($replay) {
abort_unless($exchange->owns($actor, $replay), 403);
return $replay;
}
if (isset($data['interest_id'])) {
$existing = DB::table('partnership_interests')->where('partnership_id', $id)->where('id', $data['interest_id'])->lockForUpdate()->first();
abort_unless($existing !== null, 404, 'A candidatura não pertence a esta parceria.');
} else {
$matches = DB::table('partnership_interests')->where('partnership_id', $id)->where(fn ($q) => $q->where('user_id', $actor->id)->when($data['organization_id'] ?? null, fn ($org) => $org->orWhere('organization_id', $data['organization_id'])))->orderBy('id')->lockForUpdate()->get();
abort_if($matches->count() > 1, 409, 'Há mais de uma candidatura possível. Abra a candidatura que pretende actualizar.');
$existing = $matches->first();
}
if ($existing) {
abort_unless($exchange->owns($actor, $existing), 403);
}
$organization = array_key_exists('organization_id', $data) ? $data['organization_id'] : ($existing->organization_id ?? null);
abort_if($organization && ! $exchange->represents($actor, $organization), 403);
abort_unless($this->accepting($record), 409, 'Receção de interesses encerrada.');
abort_if(isset($data['partnership_version']) && (int) $record->version !== $data['partnership_version'], 409, 'A proposta da parceria mudou. Compare os dados antes de submeter.');
abort_if((int) $record->user_id === $actor->id || $exchange->owns($actor, $record, false) || $organization && $organization === $record->organization_id, 403, 'Auto-candidatura não permitida.');
if ($organization) {
abort_if(DB::table('partnership_interests')->where('partnership_id', $id)->where('organization_id', $organization)->where('status', '!=', 'withdrawn')->when($existing, fn ($q) => $q->where('id', '!=', $existing->id))->exists(), 409, 'A organização já tem uma candidatura ativa.');
}
Validator::make($data, ['payload.sector_id' => ['nullable', 'exists:sectors,id'], 'payload.territory_id' => ['nullable', 'exists:geo_locations,id']])->validate();
if ($existing) {
abort_unless(isset($data['version']) && (int) $existing->version === $data['version'], 409, 'Candidatura existente ou alterada; abra-a para atualizar.');
abort_unless(in_array($existing->status, ['submitted', 'clarification', 'withdrawn'], true), 409);
}
$before = $existing ? $this->snapshot($existing) : null;
$payload = array_replace($existing ? Arr::only(json_decode($existing->payload, true, 512, JSON_THROW_ON_ERROR), self::PAYLOAD) : [], $data['payload']);
$fields = ['proposal' => $data['proposal'], 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'organization_id' => $organization, 'status' => 'submitted', 'assessment' => '{}', 'version' => $existing ? (int) $existing->version + 1 : 1, 'updated_at' => now()];
if ($existing) {
$interestId = (int) $existing->id;
DB::table('partnership_interests')->where('id', $interestId)->update($fields);
} else {
$interestId = (int) DB::table('partnership_interests')->insertGetId([...$fields, 'partnership_id' => $id, 'user_id' => $actor->id, 'created_at' => now()]);
}
$saved = DB::table('partnership_interests')->where('id', $interestId)->first();
$metadata = ['interest_id' => $interestId, 'interest_version' => (int) $saved->version, 'partnership_version' => (int) $record->version, 'previous' => $before, 'submitted' => $this->snapshot($saved)];
if (isset($data['client_key'])) {
$metadata += ['client_key' => $data['client_key'], 'command_hash' => $hash];
}
$exchange->event($record, $actor, 'partnership_interest.submitted.v1', $metadata, 'Manifestação de interesse recebida', [$record->user_id, $actor->id]);
$saved->command_replayed = false;
return $saved;
});
}
/** @param array<string,mixed> $assessment */
public function decide(User $actor, int $id, int $version, string $action, string $reason, array $assessment = [], ?int $partnershipVersion = null, ?string $weightsHash = null, ?string $clientKey = null): stdClass
{
$clientKey = $clientKey === null ? null : strtolower($clientKey);
$hash = $this->hash(['interest_id' => $id, 'version' => $version, 'action' => $action, 'reason' => $reason, 'assessment' => $assessment, 'partnership_version' => $partnershipVersion, 'weights_hash' => $weightsHash]);
return DB::transaction(function () use ($actor, $id, $version, $action, $reason, $assessment, $partnershipVersion, $weightsHash, $clientKey, $hash) {
$parent = DB::table('partnership_interests')->where('id', $id)->first(['partnership_id']);
abort_unless($parent !== null, 404);
$exchange = app(PartnershipExchange::class);
$record = $exchange->find($parent->partnership_id, true);
$interest = DB::table('partnership_interests')->where('id', $id)->lockForUpdate()->first();
abort_unless($interest !== null && (int) $interest->partnership_id === (int) $record->id, 409);
abort_unless($exchange->owns($actor, $action === 'withdraw' ? $interest : $record), 403);
if ($action === 'withdraw') {
abort_if($assessment !== [], 403, 'A avaliação fundamentada pertence ao responsável pela proposta.');
}
$replay = $this->replay($actor, $record, 'partnership_interest.changed.v1', $clientKey, $hash);
if ($replay) {
return $replay;
}
abort_unless((int) $interest->version === $version, 409, 'Candidatura alterada.');
abort_unless(in_array($interest->status, ['submitted', 'clarification', 'shortlisted'], true), 409);
if ($action !== 'withdraw') {
abort_if($partnershipVersion !== null && (int) $record->version !== $partnershipVersion, 409, 'A proposta ou os requisitos foram alterados. Compare antes de decidir.');
abort_if($weightsHash !== null && app(PartnershipMatching::class)->explain($record, $interest)['weights_hash'] !== $weightsHash, 409, 'Os critérios de correspondência foram alterados. Compare antes de decidir.');
}
$states = ['withdraw' => 'withdrawn', 'clarify' => 'clarification', 'shortlist' => 'shortlisted', 'reject' => 'rejected', 'connect' => 'negotiation', 'assess' => $interest->status];
abort_unless(isset($states[$action]), 422);
$before = $this->snapshot($interest);
foreach ($assessment as $code => $criterion) {
abort_unless(array_key_exists($code, config('partnerships.criteria')) && is_array($criterion), 422);
$assessment[$code] = [...Validator::make($criterion, ['state' => 'required|in:met,unmet,unknown', 'evidence' => 'required_unless:state,unknown|nullable|string|max:2000'])->validate(), 'assessed_by' => $actor->id, 'assessed_at' => now()->toIso8601String()];
}
$assessed = array_replace(json_decode($interest->assessment, true, 512, JSON_THROW_ON_ERROR), $assessment);
$matching = $action === 'withdraw' ? null : app(PartnershipMatching::class)->explain($record, $interest, $assessed);
if (in_array($action, ['shortlist', 'connect'], true)) {
abort_if(($matching['eligibility'] ?? null) === 'unmet', 422, 'Existe requisito obrigatório comprovadamente incumprido.');
}
if ($action === 'connect') {
abort_unless($interest->status === 'shortlisted', 409, 'Selecione primeiro a candidatura para lista curta.');
DB::table('partnership_rooms')->insertOrIgnore(['interest_id' => $id, 'created_at' => now(), 'updated_at' => now()]);
}
DB::table('partnership_interests')->where('id', $id)->update(['status' => $states[$action], 'assessment' => json_encode($assessed, JSON_THROW_ON_ERROR), 'decision_reason' => $reason, 'version' => $version + 1, 'updated_at' => now()]);
$saved = DB::table('partnership_interests')->where('id', $id)->first();
$metadata = ['interest_id' => $id, 'interest_version' => $version + 1, 'partnership_version' => (int) $record->version, 'action' => $action, 'from' => $interest->status, 'to' => $states[$action], 'reason' => $reason, 'assessment' => $assessed, 'matching_context' => $matching, 'previous' => $before];
if ($clientKey !== null) {
$metadata += ['client_key' => $clientKey, 'command_hash' => $hash];
}
$exchange->event($record, $actor, 'partnership_interest.changed.v1', $metadata, 'Candidatura: '.$states[$action], [$record->user_id, $interest->user_id]);
$saved->command_replayed = false;
return $saved;
});
}
/** @return array<string,mixed> */
private function snapshot(stdClass $interest): array
{
return ['version' => (int) $interest->version, 'status' => $interest->status, 'organization_id' => $interest->organization_id, 'proposal' => $interest->proposal, 'payload' => json_decode($interest->payload, true, 512, JSON_THROW_ON_ERROR), 'assessment' => json_decode($interest->assessment, true, 512, JSON_THROW_ON_ERROR), 'decision_reason' => $interest->decision_reason];
}
/** @param array<string,mixed> $command */
private function hash(array $command): string
{
return hash('sha256', json_encode(Arr::sortRecursive($command), JSON_THROW_ON_ERROR));
}
private function replay(User $actor, stdClass $record, string $type, ?string $key, string $hash): ?stdClass
{
if ($key === null) {
return null;
}
$event = DB::table('partnership_events')->where('partnership_id', $record->id)->where('actor_id', $actor->id)->where('event_type', $type)->whereRaw("payload->'payload'->>'client_key' = ?", [$key])->first(['payload']);
if (! $event) {
return null;
}
$payload = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR)['payload'];
abort_unless($payload['command_hash'] === $hash, 409, 'Chave já utilizada com outro conteúdo.');
$interest = DB::table('partnership_interests')->where('partnership_id', $record->id)->where('id', $payload['interest_id'])->first();
abort_unless($interest !== null, 409, 'A candidatura original não está disponível.');
$interest->command_replayed = true;
return $interest;
}
}
@@ -0,0 +1,198 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use stdClass;
class PartnershipCandidacyHistory
{
private const PAGE_SIZE = 10;
/** @var array<int,string|null> */
private array $people = [];
/** @var array<string,string|null> */
private array $organizations = [];
/** @return array<string,mixed> */
public function page(User $actor, int $id, ?string $before = null): array
{
$interest = DB::table('partnership_interests')->where('id', $id)->first();
abort_unless($interest !== null, 404);
$exchange = app(PartnershipExchange::class);
$record = $exchange->find($interest->partnership_id);
$responsible = $exchange->owns($actor, $record, false);
abort_unless($responsible || $exchange->owns($actor, $interest, false), 403, 'Histórico reservado aos intervenientes autorizados desta candidatura.');
$boundary = $before === null ? null : $this->decodeCursor($before, $actor, $id);
$events = DB::table('partnership_events')->where('partnership_id', $record->id)
->whereIn('event_type', ['partnership_interest.submitted.v1', 'partnership_interest.changed.v1'])
->whereRaw("payload->'payload'->>'interest_id' = ?", [(string) $id])
->when($boundary, fn ($query) => $query->where('aggregate_version', '<', $boundary))
->orderByDesc('aggregate_version')->limit(self::PAGE_SIZE + 1)->get(['aggregate_version', 'event_type', 'actor_id', 'occurred_at', 'payload']);
$hasMore = $events->count() > self::PAGE_SIZE;
$page = $events->take(self::PAGE_SIZE);
$entries = $page->map(fn ($event) => $this->entry($event, $actor, $interest, $responsible))->all();
$base = '/parcerias/candidaturas/'.$id.'/historico';
$last = $page->last();
$next = $hasMore && $last ? $base.'?before='.rawurlencode(Crypt::encryptString(json_encode(['actor' => $actor->id, 'interest' => $id, 'before' => (int) $last->aggregate_version], JSON_THROW_ON_ERROR))) : null;
return ['candidacy_id' => $id, 'entries' => $entries, 'next_url' => $next, 'first_url' => $base, 'older_page' => $before !== null,
'partnership_href' => $record->status === 'published' || $responsible ? '/parcerias/'.$record->id : null, 'dashboard_href' => '/parcerias/painel'];
}
private function decodeCursor(string $token, User $actor, int $id): int
{
try {
$cursor = json_decode(Crypt::decryptString($token), true, 512, JSON_THROW_ON_ERROR);
} catch (DecryptException|\JsonException) {
throw ValidationException::withMessages(['before' => 'A referência da página não é válida. Volte ao início do histórico.']);
}
if (! is_array($cursor) || ($cursor['actor'] ?? null) !== $actor->id || ($cursor['interest'] ?? null) !== $id || ! is_int($cursor['before'] ?? null) || $cursor['before'] < 1) {
throw ValidationException::withMessages(['before' => 'A referência da página não pertence a esta candidatura e conta. Volte ao início do histórico.']);
}
return $cursor['before'];
}
/** @return array<string,mixed> */
private function entry(stdClass $event, User $actor, stdClass $interest, bool $responsible): array
{
$envelope = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR);
$data = is_array($envelope) && is_array($envelope['payload'] ?? null) ? $envelope['payload'] : [];
$previous = is_array($data['previous'] ?? null) ? $data['previous'] : null;
$submitted = is_array($data['submitted'] ?? null) ? $data['submitted'] : null;
$decision = $event->event_type === 'partnership_interest.changed.v1';
$after = $submitted;
if ($decision) {
$after = array_replace($previous ?? [], ['version' => $data['interest_version'] ?? null, 'status' => $data['to'] ?? null, 'assessment' => $data['assessment'] ?? null, 'decision_reason' => $data['reason'] ?? null]);
}
$beforeView = $this->snapshot($previous, $actor, $interest, $responsible);
$afterView = $this->snapshot($after, $actor, $interest, $responsible);
$canRead = $responsible || $beforeView['availability'] === 'available' || $afterView['availability'] === 'available';
$version = is_int($data['interest_version'] ?? null) && $data['interest_version'] > 0 ? $data['interest_version'] : null;
if (! $canRead) {
return ['version' => $version, 'restricted' => true, 'action' => null, 'actor_name' => null, 'occurred_at' => null, 'reason' => null, 'previous' => $beforeView, 'after' => $afterView, 'matching' => null, 'partnership_version' => null];
}
$action = $decision ? ($data['action'] ?? null) : 'submit';
return ['version' => $version, 'restricted' => false, 'action' => is_string($action) && in_array($action, ['submit', 'assess', 'clarify', 'shortlist', 'reject', 'connect', 'withdraw'], true) ? $action : null,
'actor_name' => $this->person((int) $event->actor_id), 'occurred_at' => $event->occurred_at, 'reason' => is_string($data['reason'] ?? null) ? $data['reason'] : null,
'previous' => $beforeView, 'after' => $afterView, 'matching' => $afterView['availability'] === 'available' ? $this->matching($data['matching_context'] ?? null) : null,
'partnership_version' => is_int($data['partnership_version'] ?? null) ? $data['partnership_version'] : (is_int($envelope['publication_version'] ?? null) ? $envelope['publication_version'] : null)];
}
/** @param array<string,mixed>|null $snapshot
* @return array<string,mixed>
*/
private function snapshot(?array $snapshot, User $actor, stdClass $interest, bool $responsible): array
{
if ($snapshot === null) {
return ['availability' => 'not_recorded'];
}
$scopeKnown = array_key_exists('organization_id', $snapshot) && ($snapshot['organization_id'] === null || is_string($snapshot['organization_id']) && Str::isUuid($snapshot['organization_id']));
if (! $responsible && ! $scopeKnown) {
return ['availability' => 'scope_unknown'];
}
if (! $responsible && ! app(PartnershipExchange::class)->owns($actor, (object) ['organization_id' => $snapshot['organization_id'], 'user_id' => $interest->user_id], false)) {
return ['availability' => 'restricted'];
}
$organization = $scopeKnown && is_string($snapshot['organization_id']) ? $snapshot['organization_id'] : null;
$payload = is_array($snapshot['payload'] ?? null) ? $snapshot['payload'] : null;
$fields = [];
foreach (['proposal', 'decision_reason'] as $key) {
if (array_key_exists($key, $snapshot) && ($snapshot[$key] === null || is_string($snapshot[$key]))) {
$fields[$key] = $snapshot[$key];
}
}
foreach (['capacity', 'availability', 'contribution', 'conflicts'] as $key) {
if ($payload !== null && array_key_exists($key, $payload) && ($payload[$key] === null || is_string($payload[$key]))) {
$fields[$key] = $payload[$key];
}
}
$consent = $payload !== null && array_key_exists('consent', $payload) ? $payload['consent'] : null;
$knownConsent = in_array($consent, [true, false, 0, 1, '0', '1', 'yes', 'on'], true);
$references = [];
foreach (['sector_id', 'territory_id'] as $key) {
if ($payload !== null && (is_string($payload[$key] ?? null) || is_int($payload[$key] ?? null))) {
$references[$key] = (string) $payload[$key];
}
}
return ['availability' => 'available', 'version' => is_int($snapshot['version'] ?? null) ? $snapshot['version'] : null,
'status' => is_string($snapshot['status'] ?? null) ? $snapshot['status'] : null, 'scope_known' => $scopeKnown, 'organization_id' => $organization,
'organization_name' => $organization ? $this->organization($organization) : null,
'fields' => $fields, 'consent_known' => $knownConsent, 'consent' => $knownConsent ? in_array($consent, [true, 1, '1', 'yes', 'on'], true) : null,
'assessment_recorded' => is_array($snapshot['assessment'] ?? null), 'assessment' => $this->assessment($snapshot['assessment'] ?? null), 'references' => $references,
'complete' => array_key_exists('proposal', $fields) && $scopeKnown && $payload !== null && is_array($snapshot['assessment'] ?? null)];
}
/** @return list<array<string,mixed>> */
private function assessment(mixed $value): array
{
$items = [];
foreach (is_array($value) ? $value : [] as $code => $entry) {
if (! is_array($entry) || ! is_string($code)) {
continue;
}
$label = config('partnerships.criteria.'.$code, $code);
$items[] = ['code' => $code, 'label' => is_string($label) ? $label : $code, 'label_source' => 'current_catalogue',
'state' => is_string($entry['state'] ?? null) ? $entry['state'] : null, 'evidence' => is_string($entry['evidence'] ?? null) ? $entry['evidence'] : null,
'actor_name' => is_int($entry['assessed_by'] ?? null) ? $this->person($entry['assessed_by']) : null, 'assessed_at' => is_string($entry['assessed_at'] ?? null) ? $entry['assessed_at'] : null];
}
return $items;
}
/** @return array<string,mixed>|null */
private function matching(mixed $value): ?array
{
if (! is_array($value)) {
return null;
}
$criteria = [];
foreach (is_array($value['criteria'] ?? null) ? $value['criteria'] : [] as $criterion) {
if (! is_array($criterion) || ! is_string($criterion['code'] ?? null)) {
continue;
}
$projected = ['code' => $criterion['code'], 'mandatory' => is_bool($criterion['mandatory'] ?? null) ? $criterion['mandatory'] : null,
'weight' => is_int($criterion['weight'] ?? null) || is_float($criterion['weight'] ?? null) ? $criterion['weight'] : null];
foreach (['label', 'state', 'evidence', 'explanation'] as $field) {
$projected[$field] = is_string($criterion[$field] ?? null) ? $criterion[$field] : null;
}
$criteria[] = $projected;
}
$matching = ['criteria' => $criteria];
foreach (['algorithm_version', 'weights_hash', 'eligibility'] as $field) {
$matching[$field] = is_string($value[$field] ?? null) ? $value[$field] : null;
}
foreach (['score_min', 'score_max', 'unknown_weight'] as $field) {
$matching[$field] = is_int($value[$field] ?? null) || is_float($value[$field] ?? null) ? $value[$field] : null;
}
return $matching;
}
private function person(int $id): ?string
{
if (! array_key_exists($id, $this->people)) {
$this->people[$id] = DB::table('users')->where('id', $id)->value('name');
}
return $this->people[$id];
}
private function organization(string $id): ?string
{
if (! array_key_exists($id, $this->organizations)) {
$this->organizations[$id] = DB::table('organizations')->where('id', $id)->value('name');
}
return $this->organizations[$id];
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use stdClass;
class PartnershipDraftSaves
{
public function replay(User $actor, stdClass $record, ?string $key, string $hash): ?stdClass
{
if ($key === null) {
return null;
}
// The caller holds the partnership lock and has checked its current write authority.
$event = DB::table('partnership_events')->where('partnership_id', $record->id)->where('actor_id', $actor->id)->where('event_type', 'partnership_opportunity.changed.v1')->whereRaw("payload->'payload'->>'operation_key' = ?", [$key])->selectRaw("payload->'payload'->>'operation_hash' AS hash, payload->'payload'->'result_version' AS version")->first();
if (! $event) {
return null;
}
abort_unless($event->hash === $hash, 409, 'Chave de gravação já utilizada com outro conteúdo.');
$version = is_string($event->version) ? json_decode($event->version, true, 512, JSON_THROW_ON_ERROR) : $event->version;
abort_unless(is_int($version) && $version > 0, 409, 'A versão da gravação anterior não foi identificada.');
$record->command_replayed = true;
$record->command_saved_version = $version;
return $record;
}
/** @return list<array{key:string,version:int}> */
public function commands(User $actor, stdClass $record): array
{
abort_unless(app(PartnershipExchange::class)->owns($actor, $record), 403);
$events = DB::table('partnership_events')->where('partnership_id', $record->id)->where('actor_id', $actor->id)->where('event_type', 'partnership_opportunity.changed.v1')->whereRaw("payload->'payload'->>'operation_key' IS NOT NULL")->orderBy('aggregate_version')->selectRaw("payload->'payload'->>'operation_key' AS key, payload->'payload'->'result_version' AS version")->get();
$commands = [];
foreach ($events as $event) {
$version = is_string($event->version) ? json_decode($event->version, true, 512, JSON_THROW_ON_ERROR) : $event->version;
if (is_string($event->key) && Str::isUuid($event->key) && is_int($version) && $version > 0) {
$commands[] = ['key' => $event->key, 'version' => $version];
}
}
return $commands;
}
}
+131 -121
View File
@@ -3,9 +3,9 @@
namespace App\Services;
use App\Models\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
@@ -13,11 +13,68 @@ use stdClass;
class PartnershipExchange
{
private const PAYLOAD_FIELDS = ['summary', 'requirements', 'mandatory', 'confidentiality', 'consent', 'availability'];
private const ROOT_FIELDS = ['title', 'offer', 'seeking', 'organization_id', 'sector_id', 'kind', 'partnership_type', 'deadline'];
public function represents(User $actor, ?string $organization, bool $write = true): bool
{
return $organization !== null && DB::table('memberships')->where('user_id', $actor->id)->where('organization_id', $organization)->whereNull('deleted_at')
->when($write, fn ($q) => $q->whereIn('role', ['admin', 'manager', 'officer']))->exists()
&& DB::table('organizations')->where('id', $organization)->whereNull('deleted_at')->exists();
return $organization !== null && $this->representatives($organization, $write)->where('representative.user_id', $actor->id)->exists();
}
private function representatives(?string $organization, bool $write = true): Builder
{
return DB::table('memberships as representative')->join('organizations as represented', 'represented.id', '=', 'representative.organization_id')
->when($organization !== null, fn ($query) => $query->where('representative.organization_id', $organization))->whereNull('representative.deleted_at')->whereNull('represented.deleted_at')
->when($write, fn ($q) => $q->whereIn('representative.role', ['admin', 'manager', 'officer']))->select('representative.user_id');
}
/** @return list<string> */
public function readableOrganizations(User $actor): array
{
$organizations = [];
foreach ($this->representatives(null, false)->where('representative.user_id', $actor->id)->select('representative.organization_id')->pluck('representative.organization_id') as $id) {
if (! is_string($id) || ! Str::isUuid($id)) {
throw new \LogicException('Organization identity must be a UUID from the authorized catalogue.');
}
$organizations[] = $id;
}
return $organizations;
}
/** @return list<int> */
public function notificationRecipients(stdClass ...$parties): array
{
$recipients = [];
foreach ($parties as $party) {
$ids = $party->organization_id ? $this->representatives($party->organization_id)->pluck('representative.user_id')->all() : User::query()->whereKey($party->user_id)->pluck('id')->all();
foreach ($ids as $id) {
$recipients[(int) $id] = (int) $id;
}
}
return array_values($recipients);
}
/** @return list<array{id:int,name:string,parties:list<string>}> */
public function participants(stdClass $record, stdClass $interest): array
{
// Called only after room access is checked. The selector reuses the same representation query as commands.
$participants = [];
foreach (['proponent' => $record, 'candidate' => $interest] as $party => $source) {
$users = User::query()->when($source->organization_id,
fn ($q) => $q->whereIn('id', $this->representatives($source->organization_id)),
fn ($q) => $q->whereKey($source->user_id))->get(['id', 'name']);
foreach ($users as $user) {
$participants[$user->id] ??= ['id' => $user->id, 'name' => $user->name, 'parties' => []];
$participants[$user->id]['parties'][] = $party;
}
}
$participants = array_values($participants);
usort($participants, fn ($a, $b) => strnatcasecmp($a['name'], $b['name']) ?: $a['id'] <=> $b['id']);
return $participants;
}
public function owns(User $actor, stdClass $record, bool $write = true): bool
@@ -39,170 +96,114 @@ class PartnershipExchange
{
$data = Validator::make($data, [
'client_key' => [$id ? 'nullable' : 'required', 'uuid'], 'version' => [$id ? 'required' : 'nullable', 'integer', 'min:1'],
'operation_key' => ['nullable', 'uuid'],
'title' => ['nullable', 'string', 'max:200'], 'offer' => ['nullable', 'string', 'max:15000'], 'seeking' => ['nullable', 'string', 'max:15000'],
'kind' => ['required', 'in:offering,seeking'], 'partnership_type' => ['nullable', Rule::in(array_keys(config('partnerships.types')))],
'organization_id' => ['nullable', 'uuid'], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->where('is_active', true)->whereNull('deleted_at')],
'deadline' => ['nullable', 'date_format:Y-m-d'], 'payload' => ['array:summary,requirements,mandatory,confidentiality,consent,availability'],
'kind' => ['required', 'in:offering,seeking'], 'partnership_type' => ['nullable', 'string'],
'organization_id' => ['nullable', 'uuid'], 'sector_id' => ['nullable', 'integer'],
'deadline' => ['nullable', 'date_format:Y-m-d'], 'payload' => ['array:'.implode(',', self::PAYLOAD_FIELDS)],
'payload.summary' => ['nullable', 'string', 'max:15000'], 'payload.requirements' => ['nullable', 'string', 'max:10000'],
'payload.mandatory' => ['array', 'max:7'], 'payload.mandatory.*' => [Rule::in(array_keys(config('partnerships.criteria')))],
'payload.mandatory' => ['array', 'max:7'], 'payload.mandatory.*' => ['string'],
'payload.confidentiality' => ['in:public,private'], 'payload.consent' => ['boolean'], 'payload.availability' => ['nullable', 'string', 'max:1000'],
'locations' => ['array', 'max:30'], 'locations.*.geo_location_id' => ['required', 'uuid'], 'locations.*.role' => ['required', 'in:primary,operating,coverage'],
'locations.*.source_reference' => ['required', 'string', 'max:2000'],
'origins' => ['array', 'max:20'], 'origins.*.type' => ['required', 'string'], 'origins.*.target_id' => ['required', 'string', 'max:100'],
])->validate();
abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403, 'Representação da organização não autorizada.');
], ['payload.availability.max' => 'Indique a disponibilidade em até 1000 caracteres.'])->validate();
if ($id === null) {
unset($data['operation_key']);
} elseif (is_string($data['operation_key'] ?? null)) {
$data['operation_key'] = strtolower($data['operation_key']);
}
$creationHash = hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR));
$operationHash = hash('sha256', json_encode(Arr::sortRecursive(Arr::except($data, ['client_key', 'operation_key'])), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $data, $id, $creationHash) {
return DB::transaction(function () use ($actor, $data, $id, $creationHash, $operationHash) {
$creating = $id === null;
DB::select('select pg_advisory_xact_lock(71026, ?)', [$actor->id]);
if ($id) {
$record = $this->find($id, true);
abort_unless($this->owns($actor, $record), 403);
$replay = app(PartnershipDraftSaves::class)->replay($actor, $record, $data['operation_key'] ?? null, $operationHash);
if ($replay) {
return $replay;
}
abort_unless((int) $record->version === (int) $data['version'], 409, 'Rascunho alterado; recarregue.');
abort_unless(in_array($record->status, ['draft', 'returned', 'rejected', 'published'], true), 409, 'Estado não permite edição.');
} else {
$existing = DB::table('portal_partnerships')->where('user_id', $actor->id)->where('client_key', $data['client_key'])->first();
if ($existing) {
abort_unless($this->owns($actor, $existing), 403, 'A representação actual desta proposta não autoriza recuperar a gravação.');
abort_unless((json_decode($existing->payload, true)['_creation_hash'] ?? null) === $creationHash, 409, 'Chave de rascunho já utilizada com outro conteúdo.');
$existing->command_replayed = true;
return $existing;
}
$id = DB::table('portal_partnerships')->insertGetId(['user_id' => $actor->id, 'client_key' => $data['client_key'], 'title' => '', 'sector' => '', 'province' => '', 'offer' => '', 'seeking' => '', 'status' => 'draft', 'created_at' => now(), 'updated_at' => now()]);
$record = $this->find($id, true);
}
$origins = $data['origins'] ?? [];
foreach ($origins as $origin) {
$previous = $creating ? null : app(PartnershipPublication::class)->snapshot($record);
abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403, 'Representação da organização não autorizada.');
// Replaying an already committed creation is a read; live-reference checks apply to new writes.
Validator::make($data, ['partnership_type' => ['nullable', Rule::in(array_keys(config('partnerships.types')))], 'payload.mandatory.*' => [Rule::in(array_keys(config('partnerships.criteria')))], 'sector_id' => ['nullable', Rule::exists('sectors', 'id')->where('is_active', true)->whereNull('deleted_at')]], ['sector_id.exists' => 'Seleccione um sector activo ou deixe a classificação por definir.'])->validate();
$origins = $data['origins'] ?? DB::table('partnership_origins')->where('partnership_id', $id)->get(['type', 'target_id'])->map(fn ($row) => (array) $row)->all();
foreach ($data['origins'] ?? [] as $origin) {
app(PartnershipSources::class)->resolve($origin['type'], $origin['target_id'], $actor);
}
$locations = $data['locations'] ?? [];
foreach ($locations as $location) {
$locations = $data['locations'] ?? DB::table('partnership_locations')->where('partnership_id', $id)->get(['geo_location_id', 'role', 'source_reference'])->map(fn ($row) => (array) $row)->all();
foreach ($data['locations'] ?? [] as $location) {
abort_unless(app(InvestmentGeography::class)->publicReferences()->where('id', $location['geo_location_id'])->exists(), 422, 'Localização não pertence ao cadastro territorial público.');
}
DB::table('portal_partnerships')->where('id', $id)->update(['title' => $data['title'] ?? '', 'offer' => $data['offer'] ?? '', 'seeking' => $data['seeking'] ?? '',
'organization_id' => $data['organization_id'] ?? null, 'sector_id' => $data['sector_id'] ?? null, 'kind' => $data['kind'], 'partnership_type' => $data['partnership_type'] ?? null,
'deadline' => $data['deadline'] ?? null, 'payload' => json_encode([...($data['payload'] ?? []), '_creation_hash' => json_decode($record->payload, true)['_creation_hash'] ?? $creationHash], JSON_THROW_ON_ERROR),
'status' => 'draft', 'version' => $record->version + 1, 'updated_at' => now()]);
DB::table('partnership_origins')->where('partnership_id', $id)->delete();
foreach ($origins as $origin) {
DB::table('partnership_origins')->insertOrIgnore(['partnership_id' => $id, 'type' => $origin['type'], 'target_id' => $origin['target_id']]);
$existingPayload = json_decode($record->payload, true);
$payload = array_replace(Arr::only($existingPayload, self::PAYLOAD_FIELDS), $data['payload'] ?? []);
$fields = array_replace(Arr::only((array) $record, self::ROOT_FIELDS), Arr::only($data, self::ROOT_FIELDS));
foreach (['title', 'offer', 'seeking'] as $field) {
$fields[$field] ??= '';
}
DB::table('partnership_locations')->where('partnership_id', $id)->delete();
foreach ($locations as $location) {
DB::table('partnership_locations')->insertOrIgnore(['partnership_id' => $id, 'geo_location_id' => $location['geo_location_id'], 'role' => $location['role'], 'source_reference' => $location['source_reference']]);
DB::table('portal_partnerships')->where('id', $id)->update([...$fields, 'payload' => json_encode([...$payload, '_creation_hash' => $existingPayload['_creation_hash'] ?? $creationHash], JSON_THROW_ON_ERROR),
'status' => 'draft', 'version' => $record->version + 1, 'updated_at' => now()]);
if (array_key_exists('origins', $data)) {
DB::table('partnership_origins')->where('partnership_id', $id)->delete();
foreach ($origins as $origin) {
DB::table('partnership_origins')->insertOrIgnore(['partnership_id' => $id, 'type' => $origin['type'], 'target_id' => $origin['target_id']]);
}
}
if (array_key_exists('locations', $data)) {
DB::table('partnership_locations')->where('partnership_id', $id)->delete();
foreach ($locations as $location) {
DB::table('partnership_locations')->insertOrIgnore(['partnership_id' => $id, 'geo_location_id' => $location['geo_location_id'], 'role' => $location['role'], 'source_reference' => $location['source_reference']]);
}
}
$saved = $this->find($id);
$this->event($saved, $actor, 'partnership_opportunity.changed.v1', ['previous_status' => $record->status, 'origins' => $origins, 'locations' => $locations], 'Rascunho guardado', [$record->user_id]);
$metadata = ['previous_status' => $record->status, 'origins' => $origins, 'locations' => $locations, 'action' => $creating ? 'create' : 'edit', 'result_version' => (int) $saved->version, 'previous' => $previous, 'applied' => app(PartnershipPublication::class)->snapshot($saved)];
if (! $creating && isset($data['operation_key'])) {
$metadata += ['operation_key' => $data['operation_key'], 'operation_hash' => $operationHash];
}
$this->event($saved, $actor, 'partnership_opportunity.changed.v1', $metadata, 'Rascunho guardado', [$record->user_id]);
$saved->command_replayed = false;
if (! $creating && isset($data['operation_key'])) {
$saved->command_saved_version = (int) $saved->version;
}
return $saved;
});
}
public function transition(User $actor, int $id, int $version, string $action, string $reason): stdClass
public function transition(User $actor, int $id, int $version, string $action, string $reason, ?string $clientKey = null): stdClass
{
return DB::transaction(function () use ($actor, $id, $version, $action, $reason) {
$record = $this->find($id, true);
abort_unless((int) $record->version === $version, 409, 'Proposta alterada; recarregue.');
$moderation = in_array($action, ['publish', 'return', 'reject', 'suspend'], true);
if ($moderation) {
Gate::forUser($actor)->authorize('manage-portal');
abort_if($this->owns($actor, $record), 403, 'Não pode moderar a própria proposta.');
} else {
abort_unless($this->owns($actor, $record), 403);
}
$from = $record->status;
$allowed = ['submit' => ['draft', 'returned', 'rejected'], 'publish' => ['submitted', 'pending'], 'return' => ['submitted', 'pending'],
'reject' => ['submitted', 'pending'], 'suspend' => ['published'], 'archive' => ['draft', 'published', 'suspended', 'rejected'], 'renew' => ['published', 'suspended', 'archived']];
abort_unless(isset($allowed[$action]) && in_array($from, $allowed[$action], true), 409, 'Transição inválida.');
if ($action === 'submit') {
$payload = json_decode($record->payload, true);
Validator::make(['title' => $record->title, 'type' => $record->partnership_type, 'offer' => $record->offer, 'seeking' => $record->seeking, 'consent' => $payload['consent'] ?? false],
['title' => 'required', 'type' => 'required', 'offer' => 'required_without:seeking', 'seeking' => 'required_without:offer', 'consent' => 'accepted'])->validate();
}
$status = ['submit' => 'submitted', 'publish' => 'published', 'return' => 'returned', 'reject' => 'rejected', 'suspend' => 'suspended', 'archive' => 'archived', 'renew' => 'draft'][$action];
DB::table('portal_partnerships')->where('id', $id)->update(['status' => $status, 'version' => $version + 1, 'reviewed_by' => $moderation ? $actor->id : $record->reviewed_by,
'published_at' => $action === 'publish' ? now() : $record->published_at, 'updated_at' => now()]);
$saved = $this->find($id);
$this->event($saved, $actor, 'partnership_opportunity.'.$status.'.v1', ['from' => $from, 'to' => $status, 'reason' => $reason], 'Proposta: '.$status, [$record->user_id]);
return $saved;
});
return app(PartnershipPublication::class)->transition($actor, $id, $version, $action, $reason, $clientKey);
}
/** @param array<string,mixed> $data */
public function interest(User $actor, int $id, array $data): stdClass
{
$data = Validator::make($data, ['version' => ['nullable', 'integer', 'min:1'], 'organization_id' => ['nullable', 'uuid'], 'proposal' => ['required', 'string', 'max:15000'],
'payload' => ['array:capacity,availability,contribution,conflicts,consent,sector_id,territory_id'], 'payload.consent' => ['accepted'],
'payload.capacity' => ['nullable', 'string', 'max:5000'], 'payload.availability' => ['nullable', 'string', 'max:1000'], 'payload.contribution' => ['nullable', 'string', 'max:5000'], 'payload.conflicts' => ['nullable', 'string', 'max:5000'],
'payload.sector_id' => ['nullable', 'integer', 'exists:sectors,id'], 'payload.territory_id' => ['nullable', 'uuid', 'exists:geo_locations,id']])->validate();
abort_if(($data['organization_id'] ?? null) && ! $this->represents($actor, $data['organization_id']), 403);
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->find($id, true);
abort_unless($record->status === 'published' && (! $record->deadline || $record->deadline >= today()->toDateString()), 409, 'Receção de interesses encerrada.');
abort_if((int) $record->user_id === $actor->id || $this->owns($actor, $record, false) || ($data['organization_id'] ?? null) && $data['organization_id'] === $record->organization_id, 403, 'Auto-candidatura não permitida.');
$existing = DB::table('partnership_interests')->where('partnership_id', $id)->where(fn ($q) => $q->where('user_id', $actor->id)
->when($data['organization_id'] ?? null, fn ($org) => $org->orWhere('organization_id', $data['organization_id'])))->orderBy('id')->lockForUpdate()->first();
if ($data['organization_id'] ?? null) {
abort_if(DB::table('partnership_interests')->where('partnership_id', $id)->where('organization_id', $data['organization_id'])->where('status', '!=', 'withdrawn')
->when($existing, fn ($q) => $q->where('id', '!=', $existing->id))->exists(), 409, 'A organização já tem uma candidatura ativa.');
}
if ($existing) {
abort_unless($this->owns($actor, $existing), 403);
abort_unless(isset($data['version']) && (int) $existing->version === (int) $data['version'], 409, 'Candidatura existente ou alterada; abra-a para atualizar.');
abort_unless(in_array($existing->status, ['submitted', 'clarification', 'withdrawn'], true), 409);
$interestId = $existing->id;
DB::table('partnership_interests')->where('id', $interestId)->update(['proposal' => $data['proposal'], 'payload' => json_encode($data['payload']), 'organization_id' => $data['organization_id'] ?? null,
'status' => 'submitted', 'assessment' => '{}', 'version' => $existing->version + 1, 'updated_at' => now()]);
} else {
$interestId = DB::table('partnership_interests')->insertGetId(['partnership_id' => $id, 'user_id' => $actor->id, 'organization_id' => $data['organization_id'] ?? null,
'proposal' => $data['proposal'], 'payload' => json_encode($data['payload']), 'created_at' => now(), 'updated_at' => now()]);
}
$this->event($record, $actor, 'partnership_interest.submitted.v1', ['interest_id' => $interestId, 'interest_version' => $existing ? $existing->version + 1 : 1], 'Manifestação de interesse recebida', [$record->user_id, $actor->id]);
return DB::table('partnership_interests')->where('id', $interestId)->first();
});
return app(PartnershipCandidacies::class)->submit($actor, $id, $data);
}
/** @param array<string,mixed> $assessment */
public function decideInterest(User $actor, int $id, int $version, string $action, string $reason, array $assessment = []): stdClass
public function decideInterest(User $actor, int $id, int $version, string $action, string $reason, array $assessment = [], ?int $partnershipVersion = null, ?string $weightsHash = null, ?string $clientKey = null): stdClass
{
return DB::transaction(function () use ($actor, $id, $version, $action, $reason, $assessment) {
$interest = DB::table('partnership_interests')->where('id', $id)->lockForUpdate()->first();
abort_unless($interest !== null, 404);
$record = $this->find($interest->partnership_id, true);
abort_unless((int) $interest->version === $version, 409, 'Candidatura alterada.');
if ($action === 'withdraw') {
abort_if($assessment !== [], 403, 'A avaliação fundamentada pertence ao responsável pela proposta.');
abort_unless($this->owns($actor, $interest), 403);
abort_unless(in_array($interest->status, ['submitted', 'clarification', 'shortlisted'], true), 409);
} else {
abort_unless($this->owns($actor, $record), 403);
abort_unless(in_array($interest->status, ['submitted', 'clarification', 'shortlisted'], true), 409);
}
$states = ['withdraw' => 'withdrawn', 'clarify' => 'clarification', 'shortlist' => 'shortlisted', 'reject' => 'rejected', 'connect' => 'negotiation', 'assess' => $interest->status];
abort_unless(isset($states[$action]), 422);
foreach ($assessment as $code => $criterion) {
abort_unless(array_key_exists($code, config('partnerships.criteria')) && is_array($criterion), 422);
Validator::make($criterion, ['state' => 'required|in:met,unmet,unknown', 'evidence' => 'required_unless:state,unknown|nullable|string|max:2000'])->validate();
$assessment[$code]['assessed_by'] = $actor->id;
$assessment[$code]['assessed_at'] = now()->toIso8601String();
}
$assessed = array_replace(json_decode($interest->assessment, true), $assessment);
if (in_array($action, ['shortlist', 'connect'], true)) {
$match = app(PartnershipMatching::class)->explain($record, $interest, $assessed);
abort_if($match['eligibility'] === 'unmet', 422, 'Existe requisito obrigatório comprovadamente incumprido.');
}
if ($action === 'connect') {
abort_unless($interest->status === 'shortlisted', 409, 'Selecione primeiro a candidatura para lista curta.');
DB::table('partnership_rooms')->insertOrIgnore(['interest_id' => $id, 'created_at' => now(), 'updated_at' => now()]);
}
DB::table('partnership_interests')->where('id', $id)->update(['status' => $states[$action], 'assessment' => json_encode($assessed), 'decision_reason' => $reason, 'version' => $version + 1, 'updated_at' => now()]);
$this->event($record, $actor, 'partnership_interest.changed.v1', ['interest_id' => $id, 'interest_version' => $version + 1, 'from' => $interest->status, 'to' => $states[$action], 'reason' => $reason, 'assessment' => $assessed], 'Candidatura: '.$states[$action], [$record->user_id, $interest->user_id]);
return DB::table('partnership_interests')->where('id', $id)->first();
});
return app(PartnershipCandidacies::class)->decide($actor, $id, $version, $action, $reason, $assessment, $partnershipVersion, $weightsHash, $clientKey);
}
/** @return array{room:stdClass,interest:stdClass,partnership:stdClass} */
@@ -224,6 +225,15 @@ class PartnershipExchange
public function event(stdClass $record, User $actor, string $type, array $payload, string $title, array $recipients = []): void
{
DB::transaction(function () use ($record, $actor, $type, $payload, $title, $recipients): void {
if ($recipients !== []) {
$context = app(PartnershipNotifications::class)->context($record, $payload);
$payload['_notification_context'] = $context;
$parties = [(object) $context['partnership']];
if ($context['interest'] !== null) {
$parties[] = (object) $context['interest'];
}
$recipients = $this->notificationRecipients(...$parties);
}
DB::select('select pg_advisory_xact_lock(hashtextextended(?,0))', ['partnership-events:'.$record->id]);
$sequence = (DB::table('partnership_events')->where('partnership_id', $record->id)->max('aggregate_version') ?? 0) + 1;
$eventId = (string) Str::uuid();
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class PartnershipMilestones
{
/** @return array<int,string> Creation keys are private to the actor who submitted them. */
public function creationKeys(User $actor, int $partnership, int $room): array
{
$keys = [];
$events = DB::table('partnership_events')->where('partnership_id', $partnership)->where('actor_id', $actor->id)
->where('event_type', 'partnership_milestone.changed.v1')->whereRaw("payload->'payload'->>'room_id' = ?", [(string) $room])
->whereRaw("payload->'payload'->>'client_key' IS NOT NULL")->get(['payload']);
foreach ($events as $event) {
$payload = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR)['payload'];
$keys[(int) $payload['milestone_id']] = $payload['client_key'];
}
return $keys;
}
/** @param array<string,mixed> $data
* @return array{saved:bool,milestone_id:int,version:int,replayed:bool}
*/
public function save(User $actor, int $roomId, array $data, ?int $milestone = null): array
{
$command = ['title' => $data['title'], 'responsible_id' => (int) $data['responsible_id'], 'due_date' => $data['due_date'] ?? null,
'status' => $data['status'], 'evidence' => $data['evidence'] ?? null];
$creationHash = hash('sha256', json_encode($command, JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $roomId, $data, $milestone, $command, $creationHash) {
$exchange = app(PartnershipExchange::class);
$context = $exchange->room($actor, $roomId);
abort_unless($exchange->owns($actor, $context['partnership']) || $exchange->owns($actor, $context['interest']), 403);
$room = DB::table('partnership_rooms')->where('id', $roomId)->lockForUpdate()->first();
abort_unless($room !== null, 404);
if ($milestone === null && ($data['client_key'] ?? null)) {
// The room lock serializes this actor/room key check and its event, including legacy clients.
$previous = DB::table('partnership_events')->where('partnership_id', $context['partnership']->id)->where('actor_id', $actor->id)
->where('event_type', 'partnership_milestone.changed.v1')->whereRaw("payload->'payload'->>'room_id' = ?", [(string) $roomId])
->whereRaw("payload->'payload'->>'client_key' = ?", [$data['client_key']])->first(['payload']);
if ($previous) {
$payload = json_decode($previous->payload, true, 512, JSON_THROW_ON_ERROR)['payload'];
abort_unless($payload['creation_hash'] === $creationHash, 409, 'Chave de criação de marco já utilizada com outro conteúdo.');
$saved = DB::table('partnership_milestones')->where('room_id', $roomId)->where('id', $payload['milestone_id'])->first();
abort_unless($saved !== null, 409, 'O marco original não está disponível para reconciliar a criação.');
return ['saved' => true, 'milestone_id' => (int) $saved->id, 'version' => (int) $saved->version, 'replayed' => true];
}
}
abort_unless(in_array($room->status, ['agreement_recorded', 'agreed', 'execution'], true), 409, 'O estado da sala não permite alterar marcos.');
$responsible = User::query()->whereKey($command['responsible_id'])->first();
abort_unless($responsible !== null && ($exchange->owns($responsible, $context['partnership']) || $exchange->owns($responsible, $context['interest'])), 422, 'Responsável deve ser participante identificado.');
$previousResponsible = null;
$version = 1;
if ($milestone !== null) {
$existing = DB::table('partnership_milestones')->where('room_id', $roomId)->where('id', $milestone)->lockForUpdate()->first();
abort_unless($existing !== null, 404);
abort_unless((int) $existing->version === (int) $data['version'], 409, 'O marco foi alterado. Compare com os dados actuais.');
$previousResponsible = (int) $existing->responsible_id;
$version = (int) $existing->version + 1;
DB::table('partnership_milestones')->where('id', $milestone)->update([...$command, 'version' => $version, 'updated_at' => now()]);
} else {
$milestone = (int) DB::table('partnership_milestones')->insertGetId([...$command, 'room_id' => $roomId, 'created_at' => now(), 'updated_at' => now()]);
}
$metadata = ['room_id' => $roomId, 'milestone_id' => $milestone, 'status' => $command['status'], 'previous_responsible_id' => $previousResponsible, 'responsible_id' => $command['responsible_id']];
if ($version === 1 && ($data['client_key'] ?? null)) {
$metadata += ['client_key' => $data['client_key'], 'creation_hash' => $creationHash];
}
$exchange->event($context['partnership'], $actor, 'partnership_milestone.changed.v1', $metadata, 'Marco atualizado', [$context['partnership']->user_id, $context['interest']->user_id]);
return ['saved' => true, 'milestone_id' => $milestone, 'version' => $version, 'replayed' => false];
});
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use LogicException;
use stdClass;
class PartnershipNotifications
{
/** @param array<string,mixed> $payload
* @return array<string,mixed>
*/
public function context(stdClass $record, array $payload): array
{
$interest = null;
$room = null;
if (isset($payload['room_id'])) {
$room = DB::table('partnership_rooms')->where('id', $payload['room_id'])->first();
if (! $room) {
throw new LogicException('Notification room does not exist.');
}
$interest = DB::table('partnership_interests')->where('id', $room->interest_id)->first();
}
if (isset($payload['interest_id'])) {
$selected = DB::table('partnership_interests')->where('id', $payload['interest_id'])->first();
if (! $selected || $interest && (int) $selected->id !== (int) $interest->id) {
throw new LogicException('Notification candidacy is inconsistent.');
}
$interest = $selected;
}
if ($interest && (int) $interest->partnership_id !== (int) $record->id) {
throw new LogicException('Notification belongs to another partnership.');
}
return ['version' => 1, 'partnership' => $this->scope($record), 'interest' => $interest ? $this->scope($interest) : null, 'room_id' => $room ? (int) $room->id : null];
}
/** @return array{id:int,user_id:int,organization_id:string|null} */
private function scope(stdClass $record): array
{
return ['id' => (int) $record->id, 'user_id' => (int) $record->user_id, 'organization_id' => $record->organization_id];
}
/** @return list<array{id:int,title:string,href:string,read_at:string|null}> */
public function forUser(User $actor): array
{
// Filter before the visible limit. An old, inaccessible notice never conceals a newer eligible page.
return array_values($this->query($actor)->lazyByIdDesc(100, 'n.id', 'id')->map(fn ($notice) => $this->project($actor, $notice))->filter()->take(50)->all());
}
public function markRead(User $actor, int $id): void
{
DB::transaction(function () use ($actor, $id): void {
$notice = $this->query($actor)->where('n.id', $id)->lockForUpdate()->first();
abort_unless($notice && $this->project($actor, $notice) !== null, 404);
if ($notice->read_at === null) {
DB::table('partnership_notifications')->where('id', $id)->where('user_id', $actor->id)->whereNull('read_at')->update(['read_at' => now()]);
}
});
}
private function query(User $actor): Builder
{
return DB::table('partnership_notifications as n')->join('partnership_events as e', 'e.id', '=', 'n.event_id')->where('n.user_id', $actor->id)
->select(['n.id', 'n.title', 'n.read_at', 'e.partnership_id', 'e.event_type', 'e.payload']);
}
/** @return array{id:int,title:string,href:string,read_at:string|null}|null */
private function project(User $actor, stdClass $notice): ?array
{
$envelope = json_decode($notice->payload, true, 512, JSON_THROW_ON_ERROR);
$payload = is_array($envelope) && is_array($envelope['payload'] ?? null) ? $envelope['payload'] : [];
$record = DB::table('portal_partnerships')->where('id', $notice->partnership_id)->first();
if (! $record) {
return null;
}
$context = $payload['_notification_context'] ?? null;
if (array_key_exists('_notification_context', $payload)) {
if (! is_array($context) || ($context['version'] ?? null) !== 1 || ! $this->knownScope($context['partnership'] ?? null) || (int) $context['partnership']['id'] !== (int) $record->id) {
return null;
}
if (($context['interest'] ?? null) !== null && ! $this->knownScope($context['interest'])) {
return null;
}
} else {
$context = $this->legacyContext($notice, $payload);
if ($context === null) {
return null;
}
}
$interest = null;
if (is_array($context['interest'] ?? null)) {
$interest = DB::table('partnership_interests')->where('id', $context['interest']['id'])->where('partnership_id', $record->id)->first();
if (! $interest) {
return null;
}
}
$exchange = app(PartnershipExchange::class);
$proponent = is_array($context['partnership'] ?? null) && $exchange->owns($actor, $record, false) && $exchange->owns($actor, (object) $context['partnership'], false);
$candidate = $interest && $exchange->owns($actor, $interest, false) && $exchange->owns($actor, (object) $context['interest'], false);
if (! $proponent && ! $candidate) {
return null;
}
$href = '/parcerias/'.$record->id;
if ($interest) {
$href = '/parcerias/candidaturas/'.$interest->id.'/historico';
if (is_int($context['room_id'] ?? null) && DB::table('partnership_rooms')->where('id', $context['room_id'])->where('interest_id', $interest->id)->exists()) {
$href = '/parcerias/salas/'.$context['room_id'];
}
}
return ['id' => (int) $notice->id, 'title' => $notice->title, 'href' => $href, 'read_at' => $notice->read_at];
}
private function knownScope(mixed $scope): bool
{
return is_array($scope) && is_int($scope['id'] ?? null) && $scope['id'] > 0 && is_int($scope['user_id'] ?? null) && $scope['user_id'] > 0
&& array_key_exists('organization_id', $scope) && ($scope['organization_id'] === null || is_string($scope['organization_id']) && Str::isUuid($scope['organization_id']));
}
/** @param array<string,mixed> $payload
* @return array<string,mixed>|null
*/
private function legacyContext(stdClass $notice, array $payload): ?array
{
if (! in_array($notice->event_type, ['partnership_interest.submitted.v1', 'partnership_interest.changed.v1'], true) || ! is_int($payload['interest_id'] ?? null)) {
return null;
}
$snapshot = $notice->event_type === 'partnership_interest.submitted.v1' ? ($payload['submitted'] ?? null) : ($payload['previous'] ?? null);
if (! is_array($snapshot) || ! array_key_exists('organization_id', $snapshot)) {
return null;
}
$interest = DB::table('partnership_interests')->where('id', $payload['interest_id'])->where('partnership_id', $notice->partnership_id)->first();
if (! $interest) {
return null;
}
$scope = ['id' => (int) $interest->id, 'user_id' => (int) $interest->user_id, 'organization_id' => $snapshot['organization_id']];
return $this->knownScope($scope) ? ['partnership' => null, 'interest' => $scope, 'room_id' => null] : null;
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use stdClass;
class PartnershipPublication
{
private const ALLOWED = ['submit' => ['draft', 'returned', 'rejected'], 'publish' => ['submitted', 'pending'], 'return' => ['submitted', 'pending'], 'reject' => ['submitted', 'pending'], 'suspend' => ['published'], 'archive' => ['draft', 'published', 'suspended', 'rejected'], 'renew' => ['published', 'suspended', 'archived']];
private const STATES = ['submit' => 'submitted', 'publish' => 'published', 'return' => 'returned', 'reject' => 'rejected', 'suspend' => 'suspended', 'archive' => 'archived', 'renew' => 'draft'];
private const MODERATION = ['publish', 'return', 'reject', 'suspend'];
private function permitted(User $actor, stdClass $record, string $action): bool
{
$owns = app(PartnershipExchange::class)->owns($actor, $record);
return in_array($action, self::MODERATION, true) ? Gate::forUser($actor)->allows('manage-portal') && ! $owns : $owns;
}
/** @return list<string> */
public function actions(?User $actor, stdClass $record): array
{
$actions = [];
foreach (self::ALLOWED as $action => $states) {
if ($actor && in_array($record->status, $states, true) && $this->permitted($actor, $record, $action)) {
$actions[] = $action;
}
}
return $actions;
}
/** @return list<array{key:string,action:string,version:int}> */
public function commands(?User $actor, stdClass $record): array
{
if (! $actor) {
return [];
}
$commands = [];
$events = DB::table('partnership_events')->where('partnership_id', $record->id)->where('actor_id', $actor->id)->where('event_type', 'like', 'partnership_opportunity.%')->whereRaw("payload->'payload'->>'client_key' IS NOT NULL")->orderBy('aggregate_version')->get(['payload']);
foreach ($events as $event) {
$data = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR)['payload'];
if (is_string($data['client_key'] ?? null) && is_string($data['action'] ?? null) && isset(self::ALLOWED[$data['action']]) && is_int($data['result_version'] ?? null) && $this->permitted($actor, $record, $data['action'])) {
$commands[] = ['key' => $data['client_key'], 'action' => $data['action'], 'version' => $data['result_version']];
}
}
return $commands;
}
public function transition(User $actor, int $id, int $version, string $action, string $reason, ?string $clientKey = null): stdClass
{
$reason = trim($reason);
$clientKey = $clientKey === null ? null : strtolower($clientKey);
Validator::make(['version' => $version, 'reason' => $reason, 'client_key' => $clientKey], ['version' => 'required|integer|min:1', 'reason' => 'required|string|max:2000', 'client_key' => 'nullable|uuid'])->validate();
$hash = hash('sha256', json_encode(Arr::sortRecursive(['version' => $version, 'action' => $action, 'reason' => $reason]), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $id, $version, $action, $reason, $clientKey, $hash) {
$record = app(PartnershipExchange::class)->find($id, true);
abort_unless($this->permitted($actor, $record, $action), 403, 'A competência actual não permite esta acção sobre a proposta.');
if ($clientKey !== null) {
$event = DB::table('partnership_events')->where('partnership_id', $id)->where('actor_id', $actor->id)->where('event_type', 'like', 'partnership_opportunity.%')->whereRaw("payload->'payload'->>'client_key' = ?", [$clientKey])->first(['payload']);
if ($event) {
$data = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR)['payload'];
abort_unless(($data['command_hash'] ?? null) === $hash, 409, 'Chave já utilizada com outro conteúdo.');
$record->command_replayed = true;
return $record;
}
}
abort_unless((int) $record->version === $version, 409, 'Proposta alterada; compare antes de decidir.');
abort_unless(isset(self::ALLOWED[$action]) && in_array($record->status, self::ALLOWED[$action], true), 409, 'Transição inválida.');
if ($action === 'submit') {
$payload = json_decode($record->payload, true);
Validator::make(['title' => $record->title, 'type' => $record->partnership_type, 'offer' => $record->offer, 'seeking' => $record->seeking, 'consent' => $payload['consent'] ?? false], ['title' => 'required', 'type' => 'required', 'offer' => 'required_without:seeking', 'seeking' => 'required_without:offer', 'consent' => 'accepted'])->validate();
}
$before = $this->snapshot($record);
$status = self::STATES[$action];
$moderation = in_array($action, self::MODERATION, true);
DB::table('portal_partnerships')->where('id', $id)->update(['status' => $status, 'version' => $version + 1, 'reviewed_by' => $moderation ? $actor->id : $record->reviewed_by, 'published_at' => $action === 'publish' ? now() : $record->published_at, 'updated_at' => now()]);
$saved = app(PartnershipExchange::class)->find($id);
$metadata = ['from' => $record->status, 'to' => $status, 'reason' => $reason, 'action' => $action, 'result_version' => $version + 1, 'previous' => $before, 'applied' => $this->snapshot($saved)];
if ($clientKey !== null) {
$metadata += ['client_key' => $clientKey, 'command_hash' => $hash];
}
app(PartnershipExchange::class)->event($saved, $actor, 'partnership_opportunity.'.$status.'.v1', $metadata, 'Proposta: '.$status, [$record->user_id]);
$saved->command_replayed = false;
return $saved;
});
}
/** @return array<string,mixed> */
public function snapshot(stdClass $record): array
{
$payload = json_decode($record->payload, true, 512, JSON_THROW_ON_ERROR);
unset($payload['_creation_hash']);
return [...Arr::only((array) $record, ['id', 'user_id', 'organization_id', 'title', 'kind', 'partnership_type', 'sector_id', 'deadline', 'offer', 'seeking', 'version', 'status', 'published_at']),
'payload' => $payload, 'origins' => DB::table('partnership_origins')->where('partnership_id', $record->id)->get(['type', 'target_id'])->map(fn ($row) => (array) $row)->all(),
'locations' => DB::table('partnership_locations')->where('partnership_id', $record->id)->get(['geo_location_id', 'role', 'source_reference'])->map(fn ($row) => (array) $row)->all()];
}
}
@@ -0,0 +1,195 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use stdClass;
class PartnershipPublicationHistory
{
/** @var array<int,string|null> */
private array $people = [];
/** @var array<string,string|null> */
private array $organizations = [];
/** @return array<string,mixed> */
public function page(User $actor, int $id, ?string $before = null): array
{
['record' => $record, 'moderator' => $moderator] = $this->context($actor, $id);
$boundary = $before === null ? null : $this->decode($before, $actor, $id);
$events = $this->events((int) $record->id, $boundary, 11);
$page = $events->take(10);
$last = $page->last();
$base = '/parcerias/'.$id.'/historico';
$next = $events->count() > 10 && $last ? $base.'?before='.rawurlencode(Crypt::encryptString(json_encode(['purpose' => 'publication', 'actor' => $actor->id, 'partnership' => $id, 'before' => (int) $last->aggregate_version], JSON_THROW_ON_ERROR))) : null;
return ['partnership_id' => $id, 'entries' => $page->map(fn ($event) => $this->entry($event, $actor, $id, $moderator))->all(), 'first_url' => $base, 'next_url' => $next, 'older_page' => $before !== null, 'partnership_href' => '/parcerias/'.$id, 'dashboard_href' => '/parcerias/painel'];
}
/** @return array{limit:int,has_more:bool,href:string,entries:list<array<string,mixed>>} */
public function summary(User $actor, int $id): array
{
['record' => $record, 'moderator' => $moderator] = $this->context($actor, $id);
$limit = 5;
$events = $this->events((int) $record->id, null, $limit + 1);
$entries = array_values($events->take($limit)->map(function ($event) use ($actor, $id, $moderator) {
$entry = $this->entry($event, $actor, $id, $moderator);
return ['version' => $entry['version'], 'restricted' => $entry['restricted'], 'action' => $entry['action'], 'actor_name' => $entry['actor_name'], 'occurred_at' => $entry['occurred_at'], 'reason' => $entry['reason'],
'status_before' => ($entry['previous']['availability'] ?? null) === 'available' ? ($entry['previous']['status'] ?? null) : null,
'status_after' => ($entry['after']['availability'] ?? null) === 'available' ? ($entry['after']['status'] ?? null) : null];
})->all());
return ['limit' => $limit, 'has_more' => $events->count() > $limit, 'href' => '/parcerias/'.$id.'/historico', 'entries' => $entries];
}
/** @return array{record:stdClass,moderator:bool} */
private function context(User $actor, int $id): array
{
$record = app(PartnershipExchange::class)->find($id);
$moderator = Gate::forUser($actor)->allows('manage-portal');
abort_unless($moderator || app(PartnershipExchange::class)->owns($actor, $record, false), 403, 'Histórico reservado aos responsáveis autorizados pela proposta e à moderação.');
return ['record' => $record, 'moderator' => $moderator];
}
/** @return Collection<int,stdClass> */
private function events(int $id, ?int $before, int $limit): Collection
{
return DB::table('partnership_events')->where('partnership_id', $id)->where('event_type', 'like', 'partnership_opportunity.%')->when($before, fn ($q) => $q->where('aggregate_version', '<', $before))->orderByDesc('aggregate_version')->limit($limit)->get(['aggregate_version', 'event_type', 'actor_id', 'occurred_at', 'payload']);
}
private function decode(string $token, User $actor, int $id): int
{
try {
$value = json_decode(Crypt::decryptString($token), true, 512, JSON_THROW_ON_ERROR);
} catch (DecryptException|\JsonException) {
throw ValidationException::withMessages(['before' => 'A referência da página não é válida. Volte ao início do histórico.']);
}
if (! is_array($value) || ($value['purpose'] ?? null) !== 'publication' || ($value['actor'] ?? null) !== $actor->id || ($value['partnership'] ?? null) !== $id || ! is_int($value['before'] ?? null) || $value['before'] < 1) {
throw ValidationException::withMessages(['before' => 'A referência da página não pertence a esta conta e publicação. Volte ao início do histórico.']);
}
return $value['before'];
}
/** @return array<string,mixed> */
private function entry(stdClass $event, User $actor, int $id, bool $moderator): array
{
$envelope = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR);
$data = is_array($envelope) && is_array($envelope['payload'] ?? null) ? $envelope['payload'] : [];
$previous = is_array($data['previous'] ?? null) ? $data['previous'] : null;
$applied = is_array($data['applied'] ?? null) ? $data['applied'] : null;
$version = is_int($data['result_version'] ?? null) ? $data['result_version'] : (is_array($envelope) && is_int($envelope['publication_version'] ?? null) ? $envelope['publication_version'] : null);
if ($applied === null) {
$scope = $data['_notification_context']['partnership'] ?? null;
$applied = is_array($scope) ? $scope : [];
$applied['version'] = $version;
if (is_string($data['to'] ?? null)) {
$applied['status'] = $data['to'];
}
if (is_array($data['origins'] ?? null)) {
$applied['origins'] = $data['origins'];
}if (is_array($data['locations'] ?? null)) {
$applied['locations'] = $data['locations'];
}
}
if ($previous === null && (is_string($data['from'] ?? null) || is_string($data['previous_status'] ?? null))) {
$previous = ['status' => $data['from'] ?? $data['previous_status']];
}
// A synthetic insertion placeholder is not a prior version of a newly created proposal.
if (($data['action'] ?? null) === 'create' && array_key_exists('previous', $data) && $data['previous'] === null) {
$previous = null;
}
$beforeView = $this->snapshot($previous, $actor, $id, $moderator);
$afterView = $this->snapshot($applied, $actor, $id, $moderator);
$visible = $moderator || $beforeView['availability'] === 'available' || $afterView['availability'] === 'available';
$action = is_string($data['action'] ?? null) && in_array($data['action'], ['create', 'edit', 'submit', 'publish', 'return', 'reject', 'suspend', 'archive', 'renew'], true) ? $data['action'] : null;
return ['version' => $version, 'restricted' => ! $visible, 'action' => $visible ? $action : null, 'actor_name' => $visible ? $this->person((int) $event->actor_id) : null, 'occurred_at' => $visible ? $event->occurred_at : null, 'reason' => $visible && is_string($data['reason'] ?? null) ? $data['reason'] : null, 'previous' => $beforeView, 'after' => $afterView];
}
/** @param array<string,mixed>|null $snapshot
* @return array<string,mixed>
*/
private function snapshot(?array $snapshot, User $actor, int $id, bool $moderator): array
{
if ($snapshot === null) {
return ['availability' => 'not_recorded'];
}
if (array_key_exists('id', $snapshot) && (! is_int($snapshot['id']) || $snapshot['id'] !== $id)) {
return ['availability' => 'scope_unknown'];
}
$known = is_int($snapshot['id'] ?? null) && $snapshot['id'] === $id && is_int($snapshot['user_id'] ?? null) && $snapshot['user_id'] > 0 && array_key_exists('organization_id', $snapshot) && ($snapshot['organization_id'] === null || is_string($snapshot['organization_id']) && Str::isUuid($snapshot['organization_id']));
if (! $moderator && ! $known) {
return ['availability' => 'scope_unknown'];
}
if (! $moderator && ! app(PartnershipExchange::class)->owns($actor, (object) $snapshot, false)) {
return ['availability' => 'restricted'];
}
$fields = [];
foreach (['title', 'offer', 'seeking', 'kind', 'partnership_type', 'deadline', 'published_at'] as $key) {
if (array_key_exists($key, $snapshot) && ($snapshot[$key] === null || is_string($snapshot[$key]))) {
$fields[$key] = $snapshot[$key];
}
}
$payload = is_array($snapshot['payload'] ?? null) ? $snapshot['payload'] : null;
foreach (['summary', 'requirements', 'confidentiality', 'availability'] as $key) {
if ($payload !== null && array_key_exists($key, $payload) && ($payload[$key] === null || is_string($payload[$key]))) {
$fields[$key] = $payload[$key];
}
}
$consent = $payload['consent'] ?? null;
$consentKnown = in_array($consent, [true, false, 1, 0, '1', '0', 'yes', 'on'], true);
$mandatory = $payload['mandatory'] ?? null;
$mandatoryKnown = is_array($mandatory) && array_is_list($mandatory) && count(array_filter($mandatory, 'is_string')) === count($mandatory);
$origins = [];
foreach (is_array($snapshot['origins'] ?? null) ? $snapshot['origins'] : [] as $origin) {
if (is_array($origin) && is_string($origin['type'] ?? null) && $origin['type'] !== '' && is_string($origin['target_id'] ?? null) && $origin['target_id'] !== '') {
$origins[] = ['type' => $origin['type'], 'target_id' => $origin['target_id']];
}
}
$locations = [];
foreach (is_array($snapshot['locations'] ?? null) ? $snapshot['locations'] : [] as $location) {
if (is_array($location) && is_string($location['geo_location_id'] ?? null) && Str::isUuid($location['geo_location_id'])) {
$locations[] = ['geo_location_id' => $location['geo_location_id'], 'role' => is_string($location['role'] ?? null) ? $location['role'] : null, 'source_reference' => is_string($location['source_reference'] ?? null) ? $location['source_reference'] : null];
}
}
$organization = $known && is_string($snapshot['organization_id']) ? $snapshot['organization_id'] : null;
$originsIncomplete = is_array($snapshot['origins'] ?? null) && count($origins) !== count($snapshot['origins']);
$locationsIncomplete = is_array($snapshot['locations'] ?? null) && (count($locations) !== count($snapshot['locations']) || count(array_filter($locations, fn ($location) => $location['role'] === null || $location['source_reference'] === null)) > 0);
return ['availability' => 'available', 'version' => is_int($snapshot['version'] ?? null) ? $snapshot['version'] : null, 'status' => is_string($snapshot['status'] ?? null) ? $snapshot['status'] : null, 'scope_known' => $known, 'organization_id' => $organization, 'organization_name' => $organization ? $this->organization($organization) : null,
'fields' => $fields, 'sector_recorded' => array_key_exists('sector_id', $snapshot) && ($snapshot['sector_id'] === null || is_int($snapshot['sector_id']) || is_string($snapshot['sector_id'])), 'sector_id' => is_int($snapshot['sector_id'] ?? null) || is_string($snapshot['sector_id'] ?? null) ? (string) $snapshot['sector_id'] : null,
'consent_known' => $consentKnown, 'consent' => $consentKnown ? in_array($consent, [true, 1, '1', 'yes', 'on'], true) : null, 'mandatory_known' => $mandatoryKnown, 'mandatory' => $mandatoryKnown ? $mandatory : [],
'origins_recorded' => is_array($snapshot['origins'] ?? null), 'origins' => $origins, 'origins_incomplete' => $originsIncomplete,
'locations_recorded' => is_array($snapshot['locations'] ?? null), 'locations' => $locations, 'locations_incomplete' => $locationsIncomplete,
'complete' => $known && is_int($snapshot['version'] ?? null) && is_string($snapshot['status'] ?? null) && array_key_exists('title', $fields) && $payload !== null && is_array($snapshot['origins'] ?? null) && ! $originsIncomplete && is_array($snapshot['locations'] ?? null) && ! $locationsIncomplete];
}
private function person(int $id): ?string
{
if (! array_key_exists($id, $this->people)) {
$this->people[$id] = DB::table('users')->where('id', $id)->value('name');
}
return $this->people[$id];
}
private function organization(string $id): ?string
{
if (! array_key_exists($id, $this->organizations)) {
$this->organizations[$id] = DB::table('organizations')->where('id', $id)->value('name');
}
return $this->organizations[$id];
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Services;
use App\Jobs\ScanDocumentForMalware;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class PartnershipRoomUploads
{
/** @return array{uploaded:bool,document_id:string,replayed:bool} */
public function save(User $actor, int $roomId, UploadedFile $file, bool $shared, ?string $clientKey): array
{
$path = $file->getRealPath();
abort_unless(is_string($path), 503);
$hash = hash_file('sha256', $path);
abort_unless(is_string($hash), 503);
$commandHash = hash('sha256', json_encode(['sha256' => $hash, 'file_name' => basename($file->getClientOriginalName()), 'shared' => $shared], JSON_THROW_ON_ERROR));
$stored = null;
try {
$result = DB::transaction(function () use ($actor, $roomId, $file, $shared, $clientKey, $hash, $commandHash, &$stored) {
$exchange = app(PartnershipExchange::class);
$context = $exchange->room($actor, $roomId);
abort_unless($exchange->owns($actor, $context['partnership']) || $exchange->owns($actor, $context['interest']), 403);
$room = DB::table('partnership_rooms')->where('id', $roomId)->lockForUpdate()->first();
abort_unless($room !== null, 404);
if ($clientKey !== null) {
$previous = DB::table('partnership_events')->where('partnership_id', $context['partnership']->id)->where('actor_id', $actor->id)->where('event_type', 'partnership.document_added.v1')
->whereRaw("payload->'payload'->>'room_id' = ?", [(string) $roomId])->whereRaw("payload->'payload'->>'client_key' = ?", [$clientKey])->first(['payload']);
if ($previous) {
$payload = json_decode($previous->payload, true, 512, JSON_THROW_ON_ERROR)['payload'];
abort_unless($payload['upload_hash'] === $commandHash, 409, 'Chave de envio já utilizada com outro documento ou autorização.');
abort_unless(DB::table('partnership_documents')->where('room_id', $roomId)->where('document_id', $payload['document_id'])->where('owner_id', $actor->id)->exists(), 409, 'O envio original não está disponível para reconciliar.');
return ['uploaded' => true, 'document_id' => $payload['document_id'], 'replayed' => true];
}
}
abort_if(in_array($room->status, ['closed', 'cancelled'], true), 409, 'Sala encerrada.');
$documentId = (string) Str::uuid();
$stored = $file->storeAs('partnerships/'.$roomId, $documentId, 'local');
abort_unless(is_string($stored) && $stored !== '', 503);
abort_unless(hash_file('sha256', Storage::disk('local')->path($stored)) === $hash, 503, 'Os bytes armazenados não correspondem ao envio.');
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'partnership-exchange', 'data_classification' => 'restricted', 'file_name' => basename($file->getClientOriginalName()),
'media_type' => $file->getMimeType(), 'storage_key' => $stored, 'sha256' => $hash, 'status' => 'quarantined', 'size_bytes' => $file->getSize(), 'uploaded_by' => $actor->id]);
DB::table('partnership_documents')->insert(['room_id' => $roomId, 'document_id' => $documentId, 'owner_id' => $actor->id, 'shared' => $shared]);
$metadata = ['room_id' => $roomId, 'document_id' => $documentId, 'shared' => $shared];
if ($clientKey !== null) {
$metadata += ['client_key' => $clientKey, 'upload_hash' => $commandHash];
}
$exchange->event($context['partnership'], $actor, 'partnership.document_added.v1', $metadata, 'Documento privado anexado');
return ['uploaded' => true, 'document_id' => $documentId, 'replayed' => false];
});
} catch (\Throwable $error) {
if (is_string($stored) && str_starts_with($stored, 'partnerships/'.$roomId.'/')) {
Storage::disk('local')->delete($stored);
}
throw $error;
}
if (! $result['replayed']) {
ScanDocumentForMalware::dispatch($result['document_id'])->afterCommit();
}
return $result;
}
}
+13 -2
View File
@@ -5,24 +5,35 @@ namespace App\Services;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class PortalAudit
{
/** @param array<string, mixed> $data */
public static function save(Model $record, array $data, int $actor): void
public static function save(Model $record, array $data, int $actor, ?int $expectedVersion = null): void
{
DB::transaction(function () use ($record, $data, $actor) {
DB::transaction(function () use ($record, $data, $actor, $expectedVersion) {
$created = ! $record->exists;
if (! $created) {
$locked = $record->newQuery()->where($record->getKeyName(), $record->getKey())->lockForUpdate()->firstOrFail();
$record->setRawAttributes($locked->getAttributes(), true);
if ($expectedVersion !== null && (int) $locked->getAttribute('version_no') !== $expectedVersion) {
throw ValidationException::withMessages(['version' => 'Este registo foi alterado por outra operação. Compare os dados actuais antes de continuar; as suas alterações foram conservadas.']);
}
}
$versionedCrm = in_array($record->getTable(), ['investors', 'contacts', 'leads', 'tasks'], true);
// Exclude free text and personal data from the immutable event payload.
$fields = ['primary_territory_id', 'territory_version', 'version_no', 'status', 'is_active', 'is_featured', 'sort_order', 'sector_id', 'maturity_level',
'estimated_investment_amount', 'estimated_investment_currency', 'owner_user_id', 'assignee_user_id', 'due_at',
'cluster_id', 'project_id', 'corridor_id', 'dossier_id', 'interest_request_id', 'publication_status', 'editor_id', 'reviewer_id', 'role', 'stage', 'value_chain_id', 'from_value_chain_id', 'to_value_chain_id', 'link_type', 'deleted_at'];
$before = array_intersect_key($record->getAttributes(), array_flip($fields));
$record->fill($data);
if ($versionedCrm && ! $created && ! $record->isDirty()) {
return;
}
if ($versionedCrm && ! array_key_exists('version_no', $data)) {
$record->setAttribute('version_no', $created ? ($record->getAttribute('version_no') ?? 1) : (int) $record->getOriginal('version_no') + 1);
}
$changed = array_keys($record->getDirty());
$record->save();
if (! $changed) {
+4
View File
@@ -381,8 +381,12 @@ class PortalProfileAccess
}
}
$support = app(OpportunityTechnicalSupport::class);
$hasSupport = $support->allows($actor);
return ['contexts' => $contexts, 'internalPromoter' => $internalPromoter, 'mine' => $contexts ? '/minhas-oportunidades' : null, 'create' => $contexts ? '/minhas-oportunidades/create' : null,
'request' => '/perfil/promotor', 'review' => $this->available() && ($this->has($actor, 'validator') || $this->has($actor, 'publisher') || $this->has($actor, 'auditor') || $this->superuser($actor)) ? '/minhas-oportunidades' : null,
'technicalSupport' => $hasSupport ? '/suporte-tecnico/incidentes' : null, 'technicalSupportUnread' => $hasSupport ? $support->unreadCount($actor) : null,
'audit' => $this->has($actor, 'auditor') || $this->superuser($actor) ? '/perfis/auditoria' : null, 'administration' => $this->superuser($actor) ? '/perfis/gestao' : null];
}
+5 -1
View File
@@ -25,7 +25,10 @@ class PortalProfileAdministration
if (($input['internal_designation'] ?? false) === true) {
return app(PortalInternalDesignation::class)->grant($actor, $input);
}
$data = Validator::make($input, ['user_id' => 'required|integer|exists:users,id', 'role' => ['required', Rule::in(['promoter', 'validator', 'publisher', 'auditor'])],
if (($input['role'] ?? null) === 'technical_support') {
abort_unless(app(OpportunityTechnicalSupport::class)->installed(), 503, 'Instale a actualização do perfil de Suporte técnico antes de o atribuir.');
}
$data = Validator::make($input, ['user_id' => 'required|integer|exists:users,id', 'role' => ['required', Rule::in(['promoter', 'validator', 'publisher', 'auditor', 'technical_support'])],
'organization_id' => 'nullable|uuid|exists:organizations,id', 'opportunity_types' => 'required|array|min:1', 'opportunity_types.*' => ['required', Rule::in(OpportunityManagement::CATEGORIES)],
'responsibilities' => 'sometimes|array', 'responsibilities.*' => ['required', Rule::in(['validation_coordinator', 'technical_reviewer', 'legal_reviewer', 'appeal_reviewer', 'publication_coordinator'])],
'expires_at' => 'nullable|date|after:now', 'reason' => 'required|string|min:20|max:1000'])->validate();
@@ -56,6 +59,7 @@ class PortalProfileAdministration
public function insertGrant(string $id, User $approver, array $data): void
{
DB::table('portal_role_assignments')->insert(['id' => $id, 'user_id' => $data['user_id'], 'role' => $data['role'], 'organization_id' => $data['organization_id'] ?? null,
'policy_version' => $data['role'] === 'technical_support' ? 'profiles-technical-support-20261103' : PortalProfileAccess::VERSION,
'opportunity_types' => json_encode(array_values(array_unique($data['opportunity_types'])), JSON_THROW_ON_ERROR), 'responsibilities' => json_encode($data['responsibilities'] ?? [], JSON_THROW_ON_ERROR),
'source' => $data['source'] ?? 'independent-administration', 'request_id' => $data['request_id'] ?? null, 'approved_by' => $approver->id,
'starts_at' => now(), 'expires_at' => $data['expires_at'] ?? null, 'created_at' => now(), 'updated_at' => now()]);
+95 -12
View File
@@ -21,11 +21,101 @@ class PortalProfileInstallation
public function fingerprint(): string
{
$files = [...PortalProfileAccess::fingerprintFiles(), __FILE__, config_path('portal_profiles.php'),
app_path('Http/Controllers/PortalProfileInstallationController.php'), resource_path('js/pages/profiles/installation.tsx'), resource_path('js/pages/profiles/bootstrap-confirmation.tsx'), base_path('routes/web.php')];
app_path('Services/TechnicalSupportProfileSchema.php'), app_path('Services/OpportunityTechnicalSupport.php'), app_path('Services/TechnicalIncidentHandling.php'), app_path('Services/OpportunityTechnicalIncidents.php'), app_path('Services/OpportunityWorkflowExecution.php'), app_path('Exceptions/OpportunityWorkflowUnavailable.php'), app_path('Http/Controllers/TechnicalSupportController.php'),
database_path('migrations/'.TechnicalSupportProfileSchema::MIGRATION.'.php'), resource_path('js/pages/profiles/support-installation.tsx'), resource_path('js/lib/profile-support-installation.ts'), resource_path('js/lib/page-preparation-guard.ts'), resource_path('js/lib/portal-api.ts'), resource_path('js/lib/operation-key.ts'), resource_path('js/pages/profiles/technical-support.tsx'), resource_path('js/pages/profiles/technical-incident.tsx'), resource_path('js/components/technical-incident-handling.tsx'), resource_path('js/components/opportunity-technical-incidents.tsx'), resource_path('js/lib/technical-incident-handling.ts'), resource_path('js/lib/technical-incident-labels.ts'),
app_path('Jobs/ScanDocumentForMalware.php'), app_path('Services/Malware/DocumentScanAttempts.php'), app_path('Services/Malware/DocumentScanDelivery.php'), app_path('Services/Malware/DocumentScanRetry.php'), app_path('Http/Controllers/DocumentMalwareController.php'), resource_path('js/pages/management/shared.tsx'), resource_path('js/pages/investment/zone-attachments.tsx'), app_path('Services/OpportunityScanDispatch.php'), app_path('Services/OpportunityTechnicalExecution.php'), app_path('Services/OpportunityResourceFileCleanup.php'), resource_path('js/components/technical-incident-notice.tsx'), app_path('Http/Controllers/PortalProfileInstallationController.php'), resource_path('js/pages/profiles/installation.tsx'), resource_path('js/pages/profiles/bootstrap-confirmation.tsx'), base_path('routes/web.php')];
return hash('sha256', json_encode(array_map(fn ($path) => hash_file('sha256', $path), array_unique($files)), JSON_THROW_ON_ERROR));
}
/** @return array{verified:bool,date:string|null,fingerprint:string|null} */
private function readiness(\stdClass $identity, string $code): array
{
$path = config('portal_profiles.proof_directory').'/readiness.json';
$bytes = is_file($path) && is_readable($path) ? file_get_contents($path) : false;
$decoded = is_string($bytes) ? json_decode($bytes, true) : null;
$proof = is_array($decoded) ? $decoded : [];
$date = is_string($proof['verified_at'] ?? null) ? $proof['verified_at'] : null;
$timestamp = $date !== null ? strtotime($date) : false;
$target = is_array($proof['target'] ?? null) ? $proof['target'] : [];
$port = $target['port'] ?? null;
$dump = $proof['dump_path'] ?? null;
$files = $proof['files_path'] ?? null;
$verified = $timestamp !== false && $timestamp >= time() - 86400 && $timestamp <= time() && ($proof['code_hash'] ?? null) === $code && ($target['database'] ?? null) === $identity->database
&& (is_int($port) || (is_string($port) && ctype_digit($port))) && (int) $port === (int) $identity->port
&& ($proof['restore_verified'] ?? null) === true && ($proof['files_verified'] ?? null) === true && ($proof['compatibility_verified'] ?? null) === true && ($proof['qa_passed'] ?? null) === true
&& is_string($dump) && is_file($dump) && is_readable($dump) && hash_file('sha256', $dump) === ($proof['dump_sha256'] ?? null)
&& is_string($files) && is_file($files) && is_readable($files) && hash_file('sha256', $files) === ($proof['files_sha256'] ?? null);
return ['verified' => $verified, 'date' => $date, 'fingerprint' => is_string($bytes) ? hash('sha256', $bytes) : null];
}
/** @return array<string,mixed> */
public function supportPreview(User $actor): array
{
$this->authorize($actor);
$base = $this->preview($actor);
$schema = app(TechnicalSupportProfileSchema::class)->inspect();
$dependencies = ['profiles_installed' => $base['installed'], 'profiles_consistent' => $base['consistent'], ...$base['dependencies']];
$readiness = $this->readiness($base['target'], $base['code_hash']);
$action = $schema['installed'] ? 'none' : ($schema['repairable'] ? 'repair_known_base' : ($schema['state'] === 'base' && ! $schema['ledger'] ? 'install' : 'blocked'));
$plan = ['target' => $base['target'], 'actorId' => $actor->id, 'code_hash' => $base['code_hash'], 'migration' => TechnicalSupportProfileSchema::MIGRATION, 'schema' => $schema,
'dependencies' => $dependencies, 'installed' => $schema['installed'], 'consistent' => $schema['consistent'], 'repairable' => $schema['repairable'], 'action' => $action,
'ready' => ! in_array(false, $dependencies, true) && ($schema['consistent'] || $schema['repairable']) && $readiness['verified'] && $action !== 'blocked',
'backupDate' => $readiness['date'], 'readiness_fingerprint' => $readiness['fingerprint'],
'changes' => ['Acrescenta Suporte técnico aos papéis reconhecidos. Atribuir o perfil é uma operação posterior na Gestão de perfis.', 'Conserva contas, atribuições, oportunidades, incidentes e histórico.', 'Aplica apenas a actualização de perfis indicada neste destino.'],
'rollback' => 'A reversão da actualização exige preservar atribuições de Suporte técnico, incluindo as históricas; o downgrade recusa removê-las.'];
$commands = [];
foreach (DB::table('audit_events')->where('source_system', 'portal-profile-installation')->where('action', 'technical-support-install')->where('actor_user_id', $actor->id)->get(['correlation_id', 'after_data']) as $event) {
$data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR);
$commands[] = ['key' => $event->correlation_id, 'code_hash' => $data['code_hash'] ?? null];
}
$plan['hash'] = hash('sha256', json_encode($plan, JSON_THROW_ON_ERROR));
$plan['commands'] = $commands;
$plan['mfa_confirmed'] = $this->access->mfa($actor);
return $plan;
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function applySupport(User $actor, array $input): array
{
$this->authorize($actor, true);
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:1000'])->validate();
$data['idempotency_key'] = strtolower($data['idempotency_key']);
$data['confirm'] = true;
$data['reason'] = trim($data['reason']);
Validator::make(['reason' => $data['reason']], ['reason' => 'required|string|min:20|max:1000'])->validate();
return $this->receipt($actor, 'technical-support-install', $data, function () use ($actor, $data) {
$plan = $this->supportPreview($actor);
abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'O plano mudou. Consulte e compare a prévia actual.');
abort_unless($plan['consistent'] || $plan['repairable'], 409, 'A estrutura dos perfis diverge da versão reconhecida. Preserve os dados e reconcilie a divergência.');
abort_unless(! in_array(false, $plan['dependencies'], true), 422, 'Instale e confira primeiro a base dos perfis e as suas dependências.');
$identity = ['actor_id' => $actor->id, 'command_key' => $data['idempotency_key'], 'target' => $plan['target']];
if ($plan['installed']) {
return [...$identity, 'installed' => true, 'changed' => false, 'migration' => TechnicalSupportProfileSchema::MIGRATION, 'code_hash' => $plan['code_hash'], 'mode' => 'already_installed', 'flags_changed' => false, 'privileges_granted' => false];
}
abort_unless($plan['ready'] && in_array($plan['action'], ['install', 'repair_known_base'], true), 422, 'Provas actuais de backup, restauro, ficheiros e compatibilidade são necessárias.');
$previousFlag = config('portal_profiles.authenticated_installation');
config(['portal_profiles.authenticated_installation' => true]);
try {
if ($plan['action'] === 'repair_known_base') {
(require database_path('migrations/'.TechnicalSupportProfileSchema::MIGRATION.'.php'))->up();
} else {
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.TechnicalSupportProfileSchema::MIGRATION.'.php', '--force' => true]) === 0, 500, 'A actualização dos perfis falhou.');
}
} finally {
config(['portal_profiles.authenticated_installation' => $previousFlag]);
}
$after = app(TechnicalSupportProfileSchema::class)->inspect();
abort_unless($after['installed'] && $after['consistent'], 500, 'A verificação da actualização falhou; a transacção será revertida.');
return [...$identity, 'installed' => true, 'changed' => true, 'migration' => TechnicalSupportProfileSchema::MIGRATION, 'code_hash' => $plan['code_hash'], 'mode' => $plan['action'], 'flags_changed' => false, 'privileges_granted' => false];
});
}
public function designatedBootstrap(User $actor): bool
{
$emails = config('portal_profiles.bootstrap_designated_emails', []);
@@ -78,16 +168,8 @@ class PortalProfileInstallation
'existing_identity_organization_audit' => Schema::hasTable('users') && Schema::hasTable('organizations') && Schema::hasTable('memberships') && Schema::hasTable('audit_events'),
];
$code = $this->fingerprint();
$proofPath = config('portal_profiles.proof_directory').'/readiness.json';
$proofBytes = is_file($proofPath) ? file_get_contents($proofPath) : false;
$proof = is_string($proofBytes) ? json_decode($proofBytes, true) : [];
$proof = is_array($proof) ? $proof : [];
$fresh = isset($proof['verified_at']) && strtotime($proof['verified_at']) >= time() - 86400;
$evidence = $fresh && ($proof['code_hash'] ?? '') === $code && ($proof['target']['database'] ?? '') === $identity->database
&& (int) ($proof['target']['port'] ?? 0) === (int) $identity->port && ($proof['restore_verified'] ?? false) && ($proof['files_verified'] ?? false)
&& ($proof['compatibility_verified'] ?? false) && ($proof['qa_passed'] ?? false)
&& is_file($proof['dump_path'] ?? '') && hash_file('sha256', $proof['dump_path']) === ($proof['dump_sha256'] ?? null)
&& is_file($proof['files_path'] ?? '') && hash_file('sha256', $proof['files_path']) === ($proof['files_sha256'] ?? null);
$readiness = $this->readiness($identity, $code);
$evidence = $readiness['verified'];
$ready = $consistent && ! in_array(false, $dependencies, true) && $evidence;
$pair = $installed ? DB::table('portal_superuser_pair')->where('id', 1)->first() : null;
$bootstrap = ['initialized' => $pair !== null, 'holders' => $pair ? [(int) $pair->first_user_id, (int) $pair->second_user_id] : [], 'version' => $pair?->version,
@@ -96,8 +178,9 @@ class PortalProfileInstallation
->where(fn ($q) => $q->where('requested_by', $actor->id)->orWhere('subject_user_id', $actor->id))->first() : null;
$bootstrap['pending'] = $pending ? ['approval_id' => $pending->id, 'confirmation_url' => '/management/profile-install/bootstrap/'.$pending->id, 'expires_at' => $pending->expires_at] : null;
$plan = ['target' => $identity, 'code_hash' => $code, 'installed' => $installed, 'consistent' => $consistent, 'ledger' => $ledger, 'schema' => $schema,
'dependencies' => $dependencies, 'ready' => $ready, 'backupDate' => $proof['verified_at'] ?? null, 'bootstrap' => $bootstrap,
'dependencies' => $dependencies, 'ready' => $ready, 'backupDate' => $readiness['date'], 'readiness_fingerprint' => $readiness['fingerprint'], 'bootstrap' => $bootstrap,
'path_190000_installed' => DB::table('migrations')->where('migration', '2026_10_09_190000_prepare_investor_path')->exists(),
'technicalSupportUpdateUrl' => '/management/profile-install/technical-support',
'migration' => self::MIGRATION, 'changes' => ['Quatro tabelas, uma coluna em users e três em opportunities; zero contas e zero atribuições automáticas.',
'030000 precede esta ação; 190000 continua etapa posterior do percurso, sem dependência DDL desta migração.', 'Nenhuma flag ativada, dado publicado, titular escolhido ou privilégio legado removido.'],
'rollback' => 'DDL e ledger são atómicos em PostgreSQL. Se vazio, down deve remover todas as colunas adicionadas; se houver uso/histórico, preservar e restaurar backup verificado, sem rollback destrutivo.'];
+78 -1
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
@@ -70,13 +71,89 @@ final class ProactiveSupply
// Exact repeated observations are retained in storage, counted separately.
$total = (clone $base)->whereNull('o.duplicate_of')->count();
$duplicates = (clone $base)->whereNotNull('o.duplicate_of')->count();
$rows = (clone $base)->whereNull('o.duplicate_of')->orderBy('o.name')->orderBy('o.id')->offset(($page - 1) * 25)->limit(25)->get(['o.id', 'o.entity_key', 'o.name', 'o.province', 'o.district', 'o.territory_id', 'o.territory_resolution', 'o.cae', 'o.activity', 'o.sector', 'o.source_date', 'o.sheet', 'o.row_number', 'o.source_hash', 'o.identity_conflict', 's.name as source_name', 's.period as source_period', 's.imported_at']);
$rows = (clone $base)->whereNull('o.duplicate_of')->orderBy('o.name')->orderBy('o.id')->offset(($page - 1) * 25)->limit(25)->get(['o.id', 'o.name', 'o.province', 'o.district', 'o.territory_id', 'o.territory_resolution', 'o.cae', 'o.activity', 'o.sector', 'o.source_date', 'o.sheet', 'o.row_number', 'o.source_hash', 'o.identity_conflict', 's.name as source_name', 's.period as source_period', 's.imported_at']);
$coverage = DB::table('proactive_supply_observations')->selectRaw("count(*) FILTER (WHERE territory_id IS NULL) AS unmapped, count(*) FILTER (WHERE territory_resolution='province_only_district_unknown') AS province_only")->first();
return ['installed' => true, 'rows' => $rows, 'total' => $total, 'duplicate_sightings' => $duplicates, 'page' => $page, 'per_page' => 25, 'sectors' => $sectors, 'territory_coverage' => $coverage, 'limitations' => self::LIMITATIONS, 'status' => $total ? 'Registos documentais encontrados; não equivalem a empresas únicas ou oferta actual confirmada.' : 'Informação insuficiente nesta base e nestes filtros.'];
});
}
/** Review all retained observations, including those that cannot enter a territorial query.
* @param array<string,mixed> $input
* @return array<string,mixed> */
public function review(User $actor, array $input, ?Opportunity $record = null): array
{
$this->authorize($actor, $record);
$filters = Validator::make($input, [
'source_hash' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/D'],
'observation_id' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/D'],
'q' => ['nullable', 'string', 'max:120'],
'status' => ['nullable', Rule::in(['all', 'unmapped', 'ambiguous', 'territory_unavailable', 'identity_conflict', 'repeated', 'unclassified', 'unnamed'])],
'page' => ['nullable', 'integer', 'min:1', 'max:100000'],
])->validate();
$filters['status'] = $filters['status'] ?? 'unmapped';
$filters['page'] = (int) ($filters['page'] ?? 1);
if (! Schema::hasTable('proactive_supply_observations') || ! Schema::hasTable('proactive_supply_sources')) {
return ['installed' => false, 'filters' => $filters, 'sources' => [], 'coverage' => null, 'rows' => [], 'total' => null, 'per_page' => 25,
'limitations' => self::LIMITATIONS, 'status' => 'Base interna ainda não carregada neste ambiente.'];
}
return $this->read($actor, function () use ($filters): array {
$sources = DB::table('proactive_supply_sources')->orderBy('name')->orderBy('sha256')->get(['sha256', 'name', 'period', 'limitations', 'imported_at']);
if (! empty($filters['source_hash']) && ! $sources->contains('sha256', $filters['source_hash'])) {
throw ValidationException::withMessages(['source_hash' => 'A origem escolhida não está disponível nesta base interna.']);
}
$base = DB::table('proactive_supply_observations as o')->join('proactive_supply_sources as s', 's.sha256', '=', 'o.source_hash')
->leftJoinSub(app(InvestmentGeography::class)->publicReferences()->select('id', 'name', 'location_type'), 'territory', 'territory.id', '=', 'o.territory_id');
if (! empty($filters['source_hash'])) {
$base->where('o.source_hash', $filters['source_hash']);
}
$counts = (clone $base)->selectRaw("count(*) AS observations,
count(*) FILTER (WHERE o.duplicate_of IS NULL) AS distinct_observations,
count(*) FILTER (WHERE o.duplicate_of IS NOT NULL) AS repeated,
count(*) FILTER (WHERE o.territory_id IS NULL) AS unmapped,
count(*) FILTER (WHERE o.territory_resolution='ambiguous') AS ambiguous,
count(*) FILTER (WHERE o.territory_id IS NOT NULL AND territory.id IS NULL) AS territory_unavailable,
count(*) FILTER (WHERE o.identity_conflict) AS identity_conflict,
count(*) FILTER (WHERE btrim(o.activity)='') AS unclassified,
count(*) FILTER (WHERE btrim(o.name) IN ('','Denominação não indicada')) AS unnamed")->first();
$coverage = array_map(fn ($value) => (int) $value, (array) $counts);
$this->reviewStatus($base, $filters['status']);
if (! empty($filters['observation_id'])) {
$base->where('o.id', $filters['observation_id']);
}
if (! empty($filters['q'])) {
$term = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], mb_strtolower(Str::ascii(trim($filters['q']))));
$base->whereRaw("translate(lower(concat_ws(' ',o.name,o.province,o.district,o.cae,o.activity)),'áàâãéêíóôõúç','aaaaeeiooouc') LIKE ? ESCAPE '\\'", ['%'.$term.'%']);
}
$total = (clone $base)->count();
$filters['page'] = min($filters['page'], max(1, (int) ceil($total / 25)));
$rows = $base->orderBy('o.source_hash')->orderBy('o.sheet')->orderBy('o.row_number')->orderBy('o.id')
->offset(($filters['page'] - 1) * 25)->limit(25)
->get(['o.id', 'o.name', 'o.province', 'o.district', 'o.territory_id', 'o.territory_resolution', 'o.cae', 'o.activity', 'o.sector',
'o.source_date', 'o.sheet', 'o.row_number', 'o.source_hash', 'o.identity_conflict', 'o.duplicate_of',
's.name as source_name', 's.period as source_period', 's.imported_at', 'territory.name as canonical_territory', 'territory.location_type as canonical_type']);
return ['installed' => true, 'filters' => $filters, 'sources' => $sources, 'coverage' => $coverage, 'rows' => $rows,
'total' => $total, 'per_page' => 25, 'limitations' => self::LIMITATIONS,
'status' => $total ? 'Observações administrativas para conferência; não são empresas únicas nem prova de actividade actual.' : 'Informação insuficiente nesta base e nestes filtros.'];
});
}
private function reviewStatus(Builder $query, string $status): void
{
match ($status) {
'unmapped' => $query->whereNull('o.territory_id'),
'ambiguous' => $query->where('o.territory_resolution', 'ambiguous'),
'territory_unavailable' => $query->whereNotNull('o.territory_id')->whereNull('territory.id'),
'identity_conflict' => $query->where('o.identity_conflict', true),
'repeated' => $query->whereNotNull('o.duplicate_of'),
'unclassified' => $query->whereRaw("btrim(o.activity)=''"),
'unnamed' => $query->whereRaw("btrim(o.name) IN ('','Denominação não indicada')"),
default => $query,
};
}
/** Resolve server-side source lineage; a caller cannot fabricate an internal observation.
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
+307
View File
@@ -0,0 +1,307 @@
<?php
namespace App\Services;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Versioned declarations and evidence; this registry never assigns visitors or deploys changes. */
final class ProductExperiments
{
public function __construct(private readonly ProductUsageMetrics $metrics) {}
public function available(): bool
{
return Schema::hasTable('product_experiments') && Schema::hasTable('product_experiment_revisions');
}
/** @return array<string,mixed> */
public function catalogue(User $actor): array
{
abort_unless($actor->can('manage-portal'), 403);
$today = CarbonImmutable::now('UTC')->toDateString();
$report = $this->metrics->report($actor, ['from' => $today, 'to' => $today]);
return ['definition_version' => $report['definition_version'], 'services' => $report['services'], 'items' => array_map(fn ($m) => Arr::only($m, ['id', 'title', 'unit', 'source', 'limits']), $report['metrics'])];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function listing(User $actor, array $input): array
{
abort_unless($actor->can('manage-portal'), 403);
$v = Validator::make($input, ['page' => 'nullable|integer|min:1', 'state' => ['nullable', Rule::in(array_keys(config('product-experiments.states')))]])->validate();
$base = ['installed' => $this->available(), 'states' => config('product-experiments.states'), 'filters' => ['state' => $v['state'] ?? '']];
if (! $base['installed']) {
return [...$base, 'items' => [], 'page' => 1, 'pages' => 1, 'total' => 0];
}
$q = DB::table('product_experiments as p')->join('product_experiment_revisions as r', fn ($join) => $join->on('r.experiment_id', '=', 'p.id')->on('r.version', '=', 'p.version'))
->when(! empty($v['state']), fn ($q) => $q->where('p.state', $v['state']));
$total = (clone $q)->count();
$pages = max(1, (int) ceil($total / 25));
$page = min((int) ($v['page'] ?? 1), $pages);
$items = $q->orderByDesc('p.updated_at')->orderBy('p.id')->forPage($page, 25)->get(['p.id', 'p.version', 'p.state', 'p.updated_at', 'r.snapshot'])->map(function ($row) {
$snapshot = $this->decode($row->snapshot);
return ['id' => $row->id, 'version' => (int) $row->version, 'state' => $row->state, 'at' => $row->updated_at, 'title' => $snapshot['values']['title'], 'hypothesis' => $snapshot['values']['hypothesis']];
})->all();
return [...$base, 'items' => $items, 'page' => $page, 'pages' => $pages, 'total' => $total];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function view(User $actor, ?string $id, array $input = []): array
{
abort_unless($actor->can('manage-portal'), 403);
$v = Validator::make($input, ['revision' => 'nullable|integer|min:1', 'history_before' => 'nullable|integer|min:1'])->validate();
$base = ['installed' => $this->available(), 'id' => $id, 'states' => config('product-experiments.states'), 'changeKinds' => config('product-experiments.change_kinds'), 'decisions' => config('product-experiments.decisions'), 'labels' => config('product-experiments.fields'), 'catalogue' => $this->catalogue($actor)];
if ($id === null) {
return [...$base, 'currentVersion' => 0, 'historical' => false, 'document' => null, 'values' => $this->defaults(), 'history' => [], 'historyNext' => null];
}
abort_unless($base['installed'], 503, 'O registo de experiências ainda não está disponível neste destino.');
$row = DB::table('product_experiments')->where('id', $id)->firstOrFail();
$revision = (int) ($v['revision'] ?? $row->version);
$selected = DB::table('product_experiment_revisions')->where('experiment_id', $id)->where('version', $revision)->firstOrFail();
$history = DB::table('product_experiment_revisions')->where('experiment_id', $id)->when(! empty($v['history_before']), fn ($q) => $q->where('version', '<', $v['history_before']))->orderByDesc('version')->limit(26)->get(['version', 'created_at', 'snapshot']);
$next = $history->count() > 25 ? (int) $history[24]->version : null;
$document = $this->decode($selected->snapshot);
$historical = $revision !== (int) $row->version;
$vocabulary = $historical ? $document['vocabulary'] : config('product-experiments');
return [...$base, 'states' => $vocabulary['states'], 'changeKinds' => $vocabulary['change_kinds'], 'decisions' => $vocabulary['decisions'], 'labels' => $vocabulary['fields'], 'currentVersion' => (int) $row->version, 'historical' => $historical, 'document' => $document, 'values' => $document['values'], 'historyNext' => $next,
'history' => $history->take(25)->map(function ($r) {
$s = $this->decode($r->snapshot);
return ['version' => (int) $r->version, 'at' => $r->created_at, 'actor' => $s['actor']['name'], 'state' => $s['values']['state'], 'reason' => $s['reason']];
})->all()];
}
/** @return array<string,string> */
private function defaults(): array
{
$values = [];
foreach (config('product-experiments.fields') as $key => $label) {
if (! is_string($key)) {
throw new \LogicException('Experiment field identifiers must be strings.');
}
$values[$key] = '';
}
return [...$values, 'state' => 'draft'];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function save(User $actor, ?string $id, array $input): array
{
abort_unless($actor->can('manage-portal'), 403);
abort_unless($this->available(), 503, 'O registo de experiências ainda não está disponível neste destino.');
$v = Validator::make($input, ['version' => ['required', 'integer', $id === null ? 'in:0' : 'min:1'], 'operation_key' => 'required|uuid',
'reason' => [Rule::requiredIf($id !== null || ($input['values']['state'] ?? null) === 'cancelled'), 'nullable', 'string', 'min:3', 'max:3000'],
'values' => ['required', 'array:'.implode(',', array_keys(config('product-experiments.fields')))], 'values.*' => 'nullable|string|max:5000',
'values.state' => ['required', Rule::in(array_keys(config('product-experiments.states')))],
'values.change_kind' => ['nullable', Rule::in(array_keys(config('product-experiments.change_kinds')))],
'values.decision' => ['nullable', Rule::in(array_keys(config('product-experiments.decisions')))],
'values.title' => 'nullable|string|max:200', 'values.reference' => 'nullable|string|max:1500',
'values.indicator_id' => 'nullable|string|max:100', 'values.indicator_unit' => 'nullable|string|max:100',
'values.period_from' => 'nullable|date_format:Y-m-d', 'values.period_to' => 'nullable|date_format:Y-m-d',
'values.observed_from' => 'nullable|date_format:Y-m-d', 'values.observed_to' => 'nullable|date_format:Y-m-d',
'confirm_plan_change' => 'sometimes|boolean', 'confirm_observation_plan' => 'sometimes|boolean', 'refresh_indicator' => 'sometimes|boolean', 'attach_report' => 'sometimes|boolean', 'remove_report' => 'sometimes|boolean',
'indicator_definition_version' => 'nullable|string|max:100'])->validate();
$values = $this->defaults();
foreach ($v['values'] as $key => $value) {
$values[$key] = trim($value ?? '');
}
$this->validateValues($values);
$command = ['version' => (int) $v['version'], 'operation_key' => $v['operation_key'], 'reason' => trim($v['reason'] ?? ''), 'values' => $values, 'indicator_definition_version' => $v['indicator_definition_version'] ?? null];
foreach (['confirm_plan_change', 'confirm_observation_plan', 'refresh_indicator', 'attach_report', 'remove_report'] as $flag) {
$command[$flag] = (bool) ($v[$flag] ?? false);
}
$hash = $this->hash($command);
$existing = $id === null ? DB::table('product_experiments')->where('created_by', $actor->id)->where('creation_key', $v['operation_key'])->first() : DB::table('product_experiments')->where('id', $id)->firstOrFail();
if ($existing) {
$replay = DB::table('product_experiment_revisions')->where('experiment_id', $existing->id)->where('actor_id', $actor->id)->where('operation_key', $v['operation_key'])->first();
if ($replay) {
abort_unless(hash_equals($replay->request_hash, $hash), 409, 'Esta chave já foi usada para conteúdo diferente.');
return ['id' => $existing->id, 'version' => (int) $replay->version, 'replayed' => true];
}
}
// Capture an optional operational report in its own read-only consistent transaction, before mutation.
$report = null;
if ($v['attach_report'] ?? false) {
if (($v['remove_report'] ?? false) || ! $values['observed_from'] || ! $values['observed_to']) {
throw ValidationException::withMessages(['attach_report' => 'Indique o período observado; anexar e remover o relatório são acções distintas.']);
}
$report = $this->metrics->report($actor, ['from' => $values['observed_from'], 'to' => $values['observed_to'], 'service_code' => $values['report_service']]);
}
return DB::transaction(function () use ($actor, $id, $v, $values, $hash, $report) {
if ($id === null) {
DB::select('select pg_advisory_xact_lock(261103, hashtext(?))', [$actor->id.':'.$v['operation_key']]);
$row = DB::table('product_experiments')->where('created_by', $actor->id)->where('creation_key', $v['operation_key'])->lockForUpdate()->first();
} else {
$row = DB::table('product_experiments')->where('id', $id)->lockForUpdate()->firstOrFail();
}
if ($row) {
$replay = DB::table('product_experiment_revisions')->where('experiment_id', $row->id)->where('actor_id', $actor->id)->where('operation_key', $v['operation_key'])->first();
if ($replay) {
abort_unless(hash_equals($replay->request_hash, $hash), 409, 'Esta chave já foi usada para conteúdo diferente.');
return ['id' => $row->id, 'version' => (int) $replay->version, 'replayed' => true];
}
abort_unless((int) $row->version === (int) $v['version'], 409, 'O registo foi alterado. Consulte a versão actual e compare os campos antes de reaplicar as suas alterações.');
}
$previous = $row ? $this->decode(DB::table('product_experiment_revisions')->where('experiment_id', $row->id)->where('version', $row->version)->value('snapshot')) : null;
$indicator = $previous['indicator'] ?? null;
if ($values['indicator_id'] === 'manual') {
if (($indicator['id'] ?? null) !== 'manual' || $indicator['title'] !== $values['indicator_title'] || $indicator['unit'] !== $values['indicator_unit'] || $indicator['source'] !== $values['indicator_definition']) {
$indicator = ['id' => 'manual', 'title' => $values['indicator_title'], 'unit' => $values['indicator_unit'], 'source' => $values['indicator_definition'], 'limits' => 'Definição declarada pelo autor; não é medição automática do portal.', 'definition_version' => config('product-experiments.version')];
}
} elseif ($values['indicator_id'] === '') {
$indicator = null;
} elseif (($indicator['id'] ?? null) !== $values['indicator_id'] || ($v['refresh_indicator'] ?? false)) {
$catalogue = $this->catalogue($actor);
if (($v['indicator_definition_version'] ?? null) !== $catalogue['definition_version']) {
throw ValidationException::withMessages(['indicator_definition_version' => 'A definição dos indicadores mudou. Consulte a edição actual antes de a adoptar.']);
}
$selected = null;
foreach ($catalogue['items'] as $candidate) {
if ($candidate['id'] === $values['indicator_id']) {
$selected = $candidate;
break;
}
}
if (! $selected) {
throw ValidationException::withMessages(['values.indicator_id' => 'Escolha um indicador existente ou documente um indicador próprio.']);
}
$indicator = [...$selected, 'definition_version' => $catalogue['definition_version']];
}
$plan = Arr::only($values, ['change_kind', 'reference', 'hypothesis', 'indicator_id', 'period_from', 'period_to', 'segment', 'control', 'variant', 'assignment', 'criterion', 'transparency', 'consent', 'security', 'equality', 'preserves_requirements']);
$planHash = $this->hash([...$plan, 'indicator' => $indicator]);
$registration = $previous['registration'] ?? ['first_at' => null, 'original_plan_sha256' => null, 'amendments' => 0, 'amended_after_observation' => false];
$changed = $registration['first_at'] !== null && $previous['plan_sha256'] !== $planHash;
if ($changed && ! ($v['confirm_plan_change'] ?? false)) {
throw ValidationException::withMessages(['confirm_plan_change' => 'O plano já foi registado. Confirme a nova versão; as observações anteriores continuam no histórico e não comprovam o plano alterado.']);
}
if ($changed) {
$registration['amendments']++;
$registration['amended_after_observation'] = $registration['amended_after_observation'] || in_array($previous['values']['state'], ['observing', 'reviewed'], true);
}
$at = CarbonImmutable::now('UTC')->format('Y-m-d\TH:i:s.uP');
if ($registration['first_at'] === null && in_array($values['state'], ['planned', 'observing', 'reviewed'], true)) {
$registration['first_at'] = $at;
$registration['original_plan_sha256'] = $planHash;
}
$snapshotReport = ($v['remove_report'] ?? false) ? null : ($report ?? $previous['operational_report'] ?? null);
$resultFields = ['observed_from', 'observed_to', 'control_result', 'variant_result', 'result', 'limitations', 'evidence', 'decision', 'decision_reason'];
$resultHash = $this->hash(Arr::only($values, $resultFields));
$observationPlan = $previous['observation_plan_sha256'] ?? null;
if ($previous === null || $previous['result_sha256'] !== $resultHash || ($v['confirm_observation_plan'] ?? false)) {
$observationPlan = $planHash;
}
if (in_array($values['state'], ['observing', 'reviewed'], true) && $observationPlan !== $planHash) {
throw ValidationException::withMessages(['values.result' => 'As observações permanecem ligadas ao plano anterior. Reveja o resultado e documente a sua aplicação ao plano actual antes de registar a decisão.']);
}
if ($observationPlan === $planHash) {
foreach (['observed_from', 'observed_to'] as $key) {
if ($values[$key] && ((! $values['period_from'] || ! $values['period_to']) || $values[$key] < $values['period_from'] || $values[$key] > $values['period_to'])) {
throw ValidationException::withMessages(['values.'.$key => 'A observação deve corresponder ao período do plano a que fica ligada.']);
}
}
}
$snapshot = ['definition_version' => config('product-experiments.version'), 'vocabulary' => Arr::only(config('product-experiments'), ['states', 'change_kinds', 'decisions', 'fields']), 'version' => (int) ($row->version ?? 0) + 1, 'at' => $at, 'actor' => ['id' => $actor->id, 'name' => $actor->name],
'reason' => $v['reason'] ?? '', 'values' => $values, 'indicator' => $indicator, 'plan_sha256' => $planHash, 'registration' => $registration,
'result_sha256' => $resultHash, 'observation_plan_sha256' => $observationPlan, 'operational_report' => $snapshotReport,
'report_scope' => 'operational_cohort_not_experiment_arm',
'observation_reaffirmed_for_plan' => (bool) ($v['confirm_observation_plan'] ?? false),
'report_matches_observation' => $snapshotReport === null ? null : Arr::only($snapshotReport['filters'], ['from', 'to', 'service_code']) === ['from' => $values['observed_from'], 'to' => $values['observed_to'], 'service_code' => $values['report_service'] === '' ? null : $values['report_service']]];
if ($previous && $previous['values'] === $values && $previous['indicator'] === $indicator && $previous['operational_report'] === $snapshotReport && $previous['observation_plan_sha256'] === $observationPlan) {
return ['id' => $row->id, 'version' => (int) $row->version, 'unchanged' => true, 'replayed' => false];
}
$experiment = $row->id ?? (string) Str::uuid();
$revision = (string) Str::uuid();
if ($row === null) {
DB::table('product_experiments')->insert(['id' => $experiment, 'version' => 1, 'state' => $values['state'], 'created_by' => $actor->id, 'updated_by' => $actor->id, 'creation_key' => $v['operation_key'], 'created_at' => now(), 'updated_at' => now()]);
} else {
DB::table('product_experiments')->where('id', $experiment)->update(['version' => $snapshot['version'], 'state' => $values['state'], 'updated_by' => $actor->id, 'updated_at' => now()]);
}
$snapshotHash = $this->hash($snapshot);
DB::table('product_experiment_revisions')->insert(['id' => $revision, 'experiment_id' => $experiment, 'version' => $snapshot['version'], 'actor_id' => $actor->id, 'operation_key' => $v['operation_key'], 'request_hash' => $hash, 'snapshot_sha256' => $snapshotHash, 'snapshot' => Crypt::encryptString(json_encode($snapshot, JSON_THROW_ON_ERROR)), 'created_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'product-experiments', 'entity_type' => 'product_experiments', 'entity_id' => $experiment, 'actor_user_id' => $actor->id, 'action' => 'experiment_revision_recorded',
'before_data' => json_encode(['version' => (int) ($row->version ?? 0), 'state' => $row->state ?? null]), 'after_data' => json_encode(['version' => $snapshot['version'], 'state' => $values['state'], 'revision_id' => $revision, 'snapshot_sha256' => $snapshotHash])]);
return ['id' => $experiment, 'version' => $snapshot['version'], 'replayed' => false];
});
}
/** @param array<string,string> $values */
private function validateValues(array $values): void
{
$errors = [];
$complete = in_array($values['state'], ['planned', 'observing', 'reviewed'], true);
if ($complete) {
foreach (['title', 'change_kind', 'reference', 'hypothesis', 'indicator_id', 'period_from', 'period_to', 'segment', 'control', 'variant', 'assignment', 'criterion', 'transparency', 'consent', 'security', 'equality'] as $key) {
if ($values[$key] === '') {
$errors['values.'.$key] = 'Preencha este campo para registar o plano.';
}
}
if ($values['preserves_requirements'] !== 'yes') {
$errors['values.preserves_requirements'] = 'Uma experiência não pode reduzir transparência, consentimento, segurança ou igualdade de tratamento.';
}
if ($values['indicator_id'] === 'manual') {
foreach (['indicator_title', 'indicator_definition', 'indicator_unit'] as $key) {
if ($values[$key] === '') {
$errors['values.'.$key] = 'Documente o indicador próprio e a sua base de cálculo.';
}
}
}
}
if ($values['period_from'] && $values['period_to'] && $values['period_from'] > $values['period_to']) {
$errors['values.period_to'] = 'O fim não pode anteceder o início.';
}
if ($values['state'] === 'reviewed') {
foreach (['observed_from', 'observed_to', 'control_result', 'variant_result', 'result', 'limitations', 'evidence', 'decision', 'decision_reason'] as $key) {
if ($values[$key] === '') {
$errors['values.'.$key] = 'Documente a observação e a decisão; um resultado inconclusivo pode ser declarado com as suas limitações.';
}
}
}
foreach (['observed_from', 'observed_to'] as $key) {
if ($values[$key] && $values[$key] > CarbonImmutable::now('UTC')->toDateString()) {
$errors['values.'.$key] = 'Uma observação efectivamente realizada não pode ter data futura.';
}
}
if ($values['observed_from'] && $values['observed_to'] && $values['observed_from'] > $values['observed_to']) {
$errors['values.observed_to'] = 'O fim observado não pode anteceder o início.';
}
if ($values['state'] === 'observing' && ($values['observed_from'] === '' || $values['observed_from'] > CarbonImmutable::now('UTC')->toDateString())) {
$errors['values.observed_from'] = 'Indique o início efectivamente observado.';
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
}
/** @return array<string,mixed> */
private function decode(string $encrypted): array
{
return json_decode(Crypt::decryptString($encrypted), true, 512, JSON_THROW_ON_ERROR);
}
/** @param array<array-key,mixed> $value */
private function hash(array $value): string
{
return hash('sha256', json_encode(Arr::sortRecursive($value), JSON_THROW_ON_ERROR));
}
}
+170
View File
@@ -0,0 +1,170 @@
<?php
namespace App\Services;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
/** Factual aggregates over authorised operational records. No visitor tracking or success score. */
final class ProductUsageMetrics
{
public function __construct(private readonly VirtualDesk $desk, private readonly PublicSearchMetrics $searchMetrics) {}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function report(User $actor, array $input): array
{
abort_unless($actor->can('manage-portal'), 403);
$now = CarbonImmutable::now('UTC');
$v = Validator::make($input, ['from' => 'nullable|date_format:Y-m-d', 'to' => 'nullable|date_format:Y-m-d', 'service_code' => 'nullable|string|max:100', 'search_definition' => 'nullable|string|max:100'])->validate();
$from = CarbonImmutable::parse($v['from'] ?? $now->subDays(29)->toDateString(), 'UTC')->startOfDay();
$to = CarbonImmutable::parse($v['to'] ?? $now->toDateString(), 'UTC')->startOfDay();
abort_unless($from <= $to && $to <= $now->startOfDay(), 422, 'Escolha um período válido, sem datas futuras.');
$cutoff = $to->addDay()->min($now);
$service = $v['service_code'] ?? null;
$service = $service === '' ? null : $service;
$searchDefinition = $v['search_definition'] ?? null;
return DB::transaction(function () use ($from, $to, $cutoff, $service, $now, $searchDefinition) {
// Production GET owns a consistent read-only snapshot. PHPUnit may already own its rollback transaction.
$consistency = DB::transactionLevel() === 1 ? 'repeatable_read' : 'existing_transaction';
if ($consistency === 'repeatable_read') {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
}
$services = Schema::hasTable('desk_service_versions') ? DB::table('desk_service_versions')->orderBy('code')->orderByDesc('version')->get(['code', 'definition', 'version'])->unique('code')->map(fn ($s) => ['code' => $s->code, 'title' => json_decode($s->definition, true)['title'] ?? $s->code, 'version' => (int) $s->version])->values()->all() : [];
abort_if($service !== null && ! in_array($service, array_column($services, 'code'), true), 422, 'Escolha um serviço existente, incluindo as suas versões históricas.');
$start = $from->format('Y-m-d H:i:s.uP');
$end = $cutoff->format('Y-m-d H:i:s.uP');
$requestSources = Schema::hasTable('desk_requests') && Schema::hasTable('desk_service_versions');
$eventsReady = Schema::hasTable('desk_events') && Schema::hasColumn('desk_events', 'event_order');
$metrics = [];
$coverage = ['cohort_requests' => null, 'complete_histories' => null, 'unknown_histories' => null, 'classified_returned_requests' => null, 'unclassified_returned_requests' => null, 'valid_duration_requests' => null, 'invalid_duration_requests' => null];
$createdCases = null;
if (Schema::hasTable('desk_cases') && ($service === null || $requestSources)) {
$q = DB::table('desk_cases')->where('created_at', '>=', $start)->where('created_at', '<', $end);
if ($service !== null) {
$q->whereExists(fn ($sub) => $sub->selectRaw('1')->from('desk_requests as r')->join('desk_service_versions as s', 's.id', '=', 'r.service_version_id')
->whereColumn('r.case_id', 'desk_cases.id')->where('s.code', $service)->where('r.created_at', '>=', $start)->where('r.created_at', '<', $end));
}
$createdCases = $q->count();
}
$metrics[] = $this->metric('dossiers_created', 'Dossiers criados', $createdCases, 'dossiers', null, null, 'desk_cases.created_at; no segmento de serviço, exige também um pedido desse serviço criado no período.', 'Uma linha por dossier, sem somar pedidos ou inferir pessoas únicas.');
if ($requestSources) {
$requests = DB::table('desk_requests as r')->join('desk_service_versions as s', 's.id', '=', 'r.service_version_id')
->where('r.created_at', '>=', $start)->where('r.created_at', '<', $end)->when($service !== null, fn ($q) => $q->where('s.code', $service));
$total = (clone $requests)->count();
$coverage = ['cohort_requests' => $total, 'complete_histories' => 0, 'unknown_histories' => $total, 'classified_returned_requests' => 0, 'unclassified_returned_requests' => 0, 'valid_duration_requests' => 0, 'invalid_duration_requests' => 0];
$submitted = $returned = $reasonKnown = $reasonUnknown = $errorDocuments = $unsubmitted = $durationCount = $durationInvalid = 0;
$durationSum = 0.0;
if ($eventsReady) {
$requests->select(['r.id', 'r.case_id', 'r.version', 'r.created_at', 'r.updated_at'])->orderBy('r.id')->chunk(100, function ($batch) use ($end, &$coverage, &$submitted, &$returned, &$reasonKnown, &$reasonUnknown, &$errorDocuments, &$unsubmitted, &$durationCount, &$durationInvalid, &$durationSum) {
$events = DB::table('desk_events')->whereIn('request_id', $batch->pluck('id'))->where('created_at', '<', $end)->orderBy('event_order')
->select(['id', 'request_id', 'version', 'action', 'created_at', 'event_order'])
->selectRaw("CASE WHEN action = 'request_returned' THEN payload ELSE NULL END AS classification_payload")->get()->groupBy('request_id');
foreach ($batch as $request) {
$history = $events->get($request->id);
if ($history === null) {
continue;
}
$versions = $history->pluck('version')->map(fn ($version) => (int) $version)->unique();
$max = $versions->max() ?? 0;
$created = $history->contains(fn ($event) => $event->action === 'draft_created' && (int) $event->version === 1);
$currentAtCutoff = $request->updated_at === null || CarbonImmutable::parse($request->updated_at)->lt(CarbonImmutable::parse($end));
if (! $created || $max === 0 || $versions->min() !== 1 || $versions->count() !== $max || ($currentAtCutoff && $max !== (int) $request->version)) {
continue;
}
$coverage['complete_histories']++;
$first = $history->firstWhere('action', 'submitted');
if ($first === null) {
$unsubmitted++;
continue;
}
$submitted++;
$elapsed = CarbonImmutable::parse($request->created_at)->diffInSeconds(CarbonImmutable::parse($first->created_at), false);
if ($elapsed >= 0) {
$durationCount++;
$durationSum += $elapsed;
} else {
$durationInvalid++;
}
$returns = $history->filter(fn ($event) => $event->action === 'request_returned' && $event->event_order > $first->event_order);
if ($returns->isEmpty()) {
continue;
}
$returned++;
$unknown = $bad = false;
foreach ($returns as $event) {
try {
$category = $this->desk->decode($event->classification_payload)['return_reason_code'] ?? 'unspecified';
} catch (\Throwable) {
$category = 'unspecified';
}
$known = in_array($category, ['validation_error', 'missing_documents', 'other'], true);
$unknown = $unknown || ! $known;
$bad = $bad || in_array($category, ['validation_error', 'missing_documents'], true);
}
if ($bad) {
$errorDocuments++;
$reasonKnown++;
} elseif ($unknown) {
$reasonUnknown++;
} else {
$reasonKnown++;
}
}
});
}
$complete = $coverage['complete_histories'];
$coverage = [...$coverage, 'unknown_histories' => $total - $complete, 'classified_returned_requests' => $reasonKnown, 'unclassified_returned_requests' => $reasonUnknown, 'valid_duration_requests' => $durationCount, 'invalid_duration_requests' => $durationInvalid];
$metrics[] = $this->metric('requests_created', 'Pedidos criados', $total, 'pedidos', null, null, 'desk_requests.created_at e versão do serviço ligada ao pedido.', 'Coorte de pedidos criados no período; inclui cancelamentos. Não são pessoas únicas.');
$metrics[] = $this->metric('submission_rate', 'Pedidos com primeira submissão observada', $complete > 0 ? round(100 * $submitted / $complete, 2) : null, '%', $submitted, $complete, 'Primeiro evento submitted por pedido com draft_created v1 e versões contíguas até ao corte.', 'Apenas o subconjunto com historial completo; reenvios não aumentam o numerador. Não mede aprovação.', $total > $complete ? 'partial' : null);
$metrics[] = $this->metric('return_rate', 'Pedidos submetidos que tiveram devolução', $submitted > 0 ? round(100 * $returned / $submitted, 2) : null, '%', $returned, $submitted, 'Pelo menos um request_returned posterior à primeira submissão, até ao corte.', 'Mesmo pedido conta uma vez, ainda que devolvido/reenviado várias vezes. Inclui qualquer motivo.', $total > $complete ? 'partial' : null);
$metrics[] = $this->metric('error_document_return_rate', 'Devoluções por erro ou documentação em falta', $submitted > 0 && $reasonUnknown === 0 ? round(100 * $errorDocuments / $submitted, 2) : null, '%', $errorDocuments, $submitted, 'Categoria explícita no evento de devolução: validation_error ou missing_documents.', 'Motivo livre não é classificado por palavras. Devoluções históricas sem categoria podem impedir uma taxa exacta; casos com causa explicitamente comprovada contam uma vez.', $reasonUnknown > 0 || $total > $complete ? 'partial' : null);
$metrics[] = $this->metric('first_submission_seconds', 'Tempo do rascunho até à primeira submissão', $durationCount > 0 ? round($durationSum / $durationCount, 1) : null, 'segundos', null, $durationCount, 'Média de first(submitted.created_at) − desk_requests.created_at, com ordem e intervalo não negativo.', 'Não é tempo de criação do projecto, cronómetro do navegador ou SLA. Intervalos negativos ficam fora da média e são identificados.', $durationInvalid > 0 || $total > $complete ? 'partial' : null);
$metrics[] = $this->metric('not_submitted', 'Pedidos ainda sem submissão observada', $eventsReady && ($complete > 0 || $total === 0) ? $unsubmitted : null, 'pedidos', $unsubmitted, $complete, 'Pedidos com historial completo e sem submitted até ao corte.', 'Não classificar estes rascunhos/cancelamentos como abandono.', $total > $complete ? 'partial' : null);
} else {
foreach (['requests_created' => 'Pedidos criados', 'submission_rate' => 'Pedidos com primeira submissão observada', 'return_rate' => 'Pedidos submetidos que tiveram devolução', 'error_document_return_rate' => 'Devoluções por erro ou documentação em falta', 'first_submission_seconds' => 'Tempo até à primeira submissão', 'not_submitted' => 'Pedidos ainda sem submissão observada'] as $id => $label) {
$metrics[] = $this->metric($id, $label, null, '', null, null, 'Fonte do Balcão não instalada neste destino.', 'Sem fonte disponível, não apresentar zero.');
}
}
foreach (['company_comparison_saved' => ['saved_comparison', 'Dossiers com comparação guardada'], 'investment_guide_saved' => ['saved_guide', 'Dossiers com referência de guia guardada']] as $action => [$id, $label]) {
$count = null;
if ($eventsReady) {
$q = DB::table('desk_events')->where('action', $action)->where('created_at', '>=', $start)->where('created_at', '<', $end);
if ($service !== null) {
$q->whereExists(fn ($sub) => $sub->selectRaw('1')->from('desk_requests as r')->join('desk_service_versions as s', 's.id', '=', 'r.service_version_id')->whereColumn('r.case_id', 'desk_events.case_id')->where('s.code', $service)->where('r.created_at', '>=', $start)->where('r.created_at', '<', $end));
}
$count = $q->distinct()->count('case_id');
}
$metrics[] = $this->metric($id, $label, $count, 'dossiers', null, null, 'desk_events.action; case_id distinto no período, com segmento de pedidos quando seleccionado.', 'Versões/repetições de um mesmo dossier contam uma vez. Não mede consultas públicas ou pessoas únicas.');
}
$search = $this->searchMetrics->report($from->toDateString(), $to->toDateString(), $service, $searchDefinition);
$searchSource = 'Contadores diários UTC por canal/edição de recolha, lidos no snapshot do relatório. Edição '.$search['definition_version'].'.';
$searchLimits = $service === null ? 'Só respostas registadas; não mede pessoas, pesquisas únicas ou toda a utilização do portal. Sem registos não é zero.' : 'Sem associação comprovada à coorte do serviço seleccionado; não calcular para este filtro.';
$metrics[] = $this->metric('recorded_search_responses', 'Respostas de pesquisa registadas', $search['responses'], 'respostas', null, null, $searchSource, $searchLimits, $search['responses'] === null ? 'unmeasured' : 'partial');
$metrics[] = $this->metric('recorded_zero_search_responses', 'Respostas de pesquisa sem resultados principais', $search['zero_responses'], 'respostas', null, null, $searchSource, $searchLimits, $search['responses'] === null ? 'unmeasured' : 'partial');
$metrics[] = $this->metric('recorded_zero_search_rate', 'Taxa sem resultados entre respostas registadas', $search['zero_rate'], '%', $search['zero_responses'], $search['responses'], $searchSource, $searchLimits, $search['responses'] === null ? 'unmeasured' : 'partial');
$report = ['definition_version' => config('product-metrics.version'), 'generated_at' => $now->format('Y-m-d\TH:i:s.uP'),
'filters' => ['from' => $from->toDateString(), 'to' => $to->toDateString(), 'service_code' => $service, 'search_definition' => $search['definition_version']], 'timezone' => 'UTC', 'observed_before' => $cutoff->format('Y-m-d\TH:i:s.uP'),
'services' => $services, 'coverage' => $coverage, 'metrics' => $metrics, 'unmeasured' => config('product-metrics.unmeasured'), 'read_consistency' => $consistency, 'search' => $search,
'limits' => ['Contagens de dossiers, pedidos, respostas HTTP e pessoas não são somáveis.', 'As taxas do Balcão referem-se à coorte de pedidos com historial verificável; a taxa de pesquisa tem como base as respostas registadas. Não são um funil geral do portal.', 'Eventos do Balcão usam o corte temporal indicado; pesquisas usam contagens diárias no snapshot da leitura, com o dia actual parcial e sem corte por fracção de segundo.', 'A leitura usa as permissões existentes. Os contadores de pesquisa não guardam termos, valores de filtros, IP, cookies, utilizadores ou identificadores de visitantes.', 'O fingerprint identifica o conteúdo deste relatório, não certifica a veracidade dos registos de origem.']];
$report['fingerprint'] = hash('sha256', json_encode($report, JSON_THROW_ON_ERROR));
return $report;
});
}
/** @return array<string,mixed> */
private function metric(string $id, string $title, int|float|null $value, string $unit, ?int $numerator, ?int $denominator, string $source, string $limits, ?string $state = null): array
{
return ['id' => $id, 'title' => $title, 'value' => $value, 'unit' => $unit, 'numerator' => $numerator, 'denominator' => $denominator,
'state' => $state ?? ($value === null ? 'unmeasured' : 'observed'), 'source' => $source, 'limits' => $limits];
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Services;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
/** Disclosure is decided by the stored public flag and declared precision, never inferred from an address. */
final class PublicBusinessLocations
{
/** @return literal-string */
public function sourceProjection(): string
{
return app(BusinessPointPublication::class)->available()
? 'l.public_source_reference AS location_source_reference, l.source_date AS location_source_date'
: 'NULL::text AS location_source_reference, NULL::date AS location_source_date';
}
public function disclosed(): Builder
{
$query = DB::table('existing_business_locations as l')->where('l.is_public', true);
foreach (['province_id' => ['province', 'district', 'admin_post', 'locality', 'exact'],
'district_id' => ['district', 'admin_post', 'locality', 'exact'],
'admin_post_id' => ['admin_post', 'locality', 'exact'], 'locality_id' => ['locality', 'exact']] as $field => $precisions) {
$query->where(fn (Builder $scope) => $scope->whereNotIn('l.precision_level', $precisions)->orWhereNull('l.'.$field)
->orWhereIn('l.'.$field, app(InvestmentGeography::class)->publicReferences()->select('id')));
}
return $query;
}
public function primary(): Builder
{
return $this->disclosed()->where('l.is_primary', true);
}
public function points(): Builder
{
// Nested CASE is intentional: ST_X/ST_Y must never receive a polygon or collection.
return $this->primary()->where('l.precision_level', 'exact')
->select('l.existing_business_id', 'l.updated_at as location_recorded_at')
->selectRaw($this->sourceProjection())
->selectRaw("CASE WHEN GeometryType(l.geom)='POINT' AND ST_IsValid(l.geom) AND NOT ST_IsEmpty(l.geom) AND ST_SRID(l.geom)=4326
THEN CASE WHEN ST_X(l.geom) BETWEEN -180 AND 180 AND ST_Y(l.geom) BETWEEN -90 AND 90 THEN l.geom END END AS point");
}
/** @param list<string> $ids */
public function matchingArea(array $ids): Builder
{
return $this->disclosed()->where(function (Builder $query) use ($ids): void {
$query->whereIn('l.province_id', $ids)
->orWhere(fn (Builder $level) => $level->whereIn('l.precision_level', ['district', 'admin_post', 'locality', 'exact'])->whereIn('l.district_id', $ids))
->orWhere(fn (Builder $level) => $level->whereIn('l.precision_level', ['admin_post', 'locality', 'exact'])->whereIn('l.admin_post_id', $ids))
->orWhere(fn (Builder $level) => $level->whereIn('l.precision_level', ['locality', 'exact'])->whereIn('l.locality_id', $ids));
});
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace App\Services;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
/** Only anonymous daily counters; no query, filter values, visitor or request identifier is stored. */
final class PublicSearchMetrics
{
public const ATTRIBUTE = 'product.search_metric';
/** @param array<array-key,mixed> $filters */
public function candidate(Request $request, string $channel, int $total, array $filters): void
{
if (! config('search-metrics.enabled') || ! array_key_exists($channel, config('search-metrics.channels')) || $total < 0) {
return;
}
if (! $request->isMethod('GET') || $request->integer('page', 1) !== 1) {
return;
}
foreach ($filters as $key => $value) {
if (! is_string($key)) {
continue;
}
if (in_array($key, config('search-metrics.presentation_keys'), true) || ! $request->query->has($key)) {
continue;
}
if ($value === null || $value === '' || $value === [] || $value === false || (is_string($value) && trim($value) === '')) {
continue;
}
$request->attributes->set(self::ATTRIBUTE, ['channel' => $channel, 'empty' => $total === 0]);
return;
}
}
public function record(string $channel, bool $empty): void
{
if (! config('search-metrics.enabled') || ! array_key_exists($channel, config('search-metrics.channels'))) {
return;
}
try {
// A savepoint also protects an existing request transaction if collection fails.
DB::transaction(function () use ($channel, $empty) {
$settings = DB::selectOne("SELECT current_setting('lock_timeout') AS old_lock, current_setting('statement_timeout') AS old_statement, (SELECT setting::bigint FROM pg_settings WHERE name='lock_timeout') AS lock_ms, (SELECT setting::bigint FROM pg_settings WHERE name='statement_timeout') AS statement_ms");
$lock = $this->budget('capture_lock_timeout_ms', 100);
$statement = $this->budget('capture_statement_timeout_ms', 300);
if ($settings->lock_ms > 0) {
$lock = min($lock, $settings->lock_ms);
}
if ($settings->statement_ms > 0) {
$statement = min($statement, $settings->statement_ms);
}
DB::selectOne("SELECT set_config('lock_timeout',?,true), set_config('statement_timeout',?,true)", [$lock.'ms', $statement.'ms']);
if (Schema::hasTable('product_search_counts') && Schema::hasTable('product_search_definitions')) {
$at = CarbonImmutable::now('UTC');
$definition = $this->definition();
$hash = hash('sha256', json_encode(Arr::sortRecursive($definition), JSON_THROW_ON_ERROR));
DB::table('product_search_definitions')->insertOrIgnore(['version' => $definition['version'], 'sha256' => $hash, 'definition' => json_encode($definition, JSON_THROW_ON_ERROR), 'created_at' => $at->format('Y-m-d H:i:s.uP')]);
if (DB::table('product_search_definitions')->where('version', $definition['version'])->value('sha256') !== $hash) {
throw new \LogicException('search_definition_conflict');
}
DB::statement(<<<'SQL'
INSERT INTO product_search_counts(day,channel,definition_version,responses,zero_responses,first_recorded_at,last_recorded_at)
VALUES(?,?,?,1,?,?,?)
ON CONFLICT(day,channel,definition_version) DO UPDATE SET
responses=product_search_counts.responses+1,
zero_responses=product_search_counts.zero_responses+EXCLUDED.zero_responses,
first_recorded_at=LEAST(product_search_counts.first_recorded_at,EXCLUDED.first_recorded_at),
last_recorded_at=GREATEST(product_search_counts.last_recorded_at,EXCLUDED.last_recorded_at)
SQL, [$at->toDateString(), $channel, config('search-metrics.version'), $empty ? 1 : 0, $at->format('Y-m-d H:i:s.uP'), $at->format('Y-m-d H:i:s.uP')]);
}
// Successful nested transactions must restore the caller's settings explicitly.
// On error, rollback to the savepoint restores both settings automatically.
DB::selectOne("SELECT set_config('lock_timeout',?,true), set_config('statement_timeout',?,true)", [$settings->old_lock, $settings->old_statement]);
});
} catch (\Throwable $error) {
// Never log the exception message/SQL, request, terms, headers or actor.
try {
Log::warning('product-search-metrics-unavailable', ['channel' => $channel, 'definition_version' => config('search-metrics.version'), 'exception_class' => $error::class]);
} catch (\Throwable) {
// Logging failure must not replace the already-produced search response either.
}
}
}
/** Called inside the authorised report snapshot. Daily aggregates have their own time basis.
* @return array<string,mixed> */
public function report(string $from, string $to, ?string $service, ?string $requestedVersion): array
{
$installed = Schema::hasTable('product_search_counts') && Schema::hasTable('product_search_definitions');
$registered = $installed ? DB::table('product_search_definitions')->orderBy('version')->get()->keyBy('version') : null;
$current = $this->definition();
$version = $requestedVersion === null || $requestedVersion === '' ? $current['version'] : $requestedVersion;
$available = array_values(array_unique([...($registered?->pluck('version')->all() ?? []), $current['version']]));
if (! in_array($version, $available, true)) {
throw ValidationException::withMessages(['search_definition' => 'Escolha uma edição de recolha existente.']);
}
$stored = $registered?->get($version);
$definition = $stored ? json_decode($stored->definition, true, 512, JSON_THROW_ON_ERROR) : $current;
$rows = $installed && $service === null ? DB::table('product_search_counts')->where('definition_version', $version)->whereBetween('day', [$from, $to])->orderBy('day')->orderBy('channel')->get() : null;
$channels = [];
foreach ($definition['channels'] as $code => $label) {
$selected = $rows?->where('channel', $code);
$known = $selected !== null && $selected->isNotEmpty();
$count = $known ? $selected->sum('responses') : null;
$zeros = $known ? $selected->sum('zero_responses') : null;
$channels[] = ['id' => $code, 'label' => $label, 'responses' => $count, 'zero_responses' => $zeros, 'zero_rate' => $count > 0 ? round(100 * $zeros / $count, 2) : null,
'first_recorded_at' => $known ? $selected->min('first_recorded_at') : null, 'last_recorded_at' => $known ? $selected->max('last_recorded_at') : null,
'definition_versions' => $selected?->pluck('definition_version')->unique()->values()->all() ?? []];
}
$known = $rows !== null && $rows->isNotEmpty();
$responses = $known ? $rows->sum('responses') : null;
$zeros = $known ? $rows->sum('zero_responses') : null;
return ['installed' => $installed, 'enabled_now' => (bool) config('search-metrics.enabled'), 'applicable' => $service === null,
'from' => $from, 'to' => $to, 'timezone' => 'UTC', 'read_at' => DB::selectOne('select statement_timestamp() AS at')->at,
'definition_version' => $version, 'available_definitions' => $available, 'definition' => $definition, 'definition_conflict_now' => $stored !== null && $version === $current['version'] && $stored->sha256 !== hash('sha256', json_encode(Arr::sortRecursive($current), JSON_THROW_ON_ERROR)),
'responses' => $responses, 'zero_responses' => $zeros, 'zero_rate' => $responses > 0 ? round(100 * $zeros / $responses, 2) : null,
'channels' => $channels, 'exclusions' => $definition['exclusions'], 'completeness' => 'unverified_best_effort',
'temporal_basis' => 'daily_aggregate_at_report_snapshot',
'limits' => ['São respostas de primeira página com critérios explícitos, não pessoas ou pesquisas únicas.', 'Sem linhas no período significa ausência de registos de medição, não zero pesquisas.', 'A recolha não bloqueia a pesquisa; falhas de recolha podem deixar respostas por contar. A taxa refere-se apenas às respostas registadas.', 'Contagens por dia UTC são lidas no snapshot do relatório; o dia actual é parcial e não permite um corte por fracção de segundo.', 'Um filtro de serviço do Balcão não se aplica a estes catálogos públicos; nesse caso, os valores ficam sem cálculo.', 'As sugestões relacionadas não alteram o total principal usado para classificar uma resposta sem resultados.']];
}
/** @return array<string,mixed> */
private function definition(): array
{
return ['version' => config('search-metrics.version'), 'channels' => config('search-metrics.channels'), 'presentation_keys' => config('search-metrics.presentation_keys'), 'exclusions' => config('search-metrics.exclusions'),
'unit' => 'successful_first_page_http_search_response', 'zero' => 'primary_result_total_equals_zero', 'privacy' => 'daily_channel_counts_only_no_terms_filters_identifiers_or_visitors'];
}
private function budget(string $key, int $default): int
{
$value = filter_var(config('search-metrics.'.$key), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 5000]]);
return $value === false ? $default : $value;
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/** Append-only technical treatment; never invokes an investment transition. */
final class TechnicalIncidentHandling
{
private const SOURCE = 'portal-opportunity-support';
/** Optional read projection must not block the investment editor or other incidents.
* @return array{version:int,status:string,assignee_user_id:int|null,assignee_name:string|null,updated_at:string|null,resolved_at:string|null}|null */
public function summaryFor(string $reference): ?array
{
try {
return $this->stateFor($reference);
} catch (HttpExceptionInterface $error) {
if ($error->getStatusCode() !== 503) {
throw $error;
}
return null;
}
}
private function events(string $reference): Builder
{
return DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'opportunity-technical-incident')->where('entity_id', $reference)->where('action', 'incident_handled');
}
/** Caller has already authorized the root incident.
* @return array{version:int,status:string,assignee_user_id:int|null,assignee_name:string|null,updated_at:string|null,resolved_at:string|null} */
public function stateFor(string $reference): array
{
$event = $this->events($reference)->orderByRaw("(after_data->'handling'->'version') DESC NULLS FIRST")->orderByDesc('id')->first(['after_data']);
if ($event === null) {
return ['version' => 0, 'status' => 'open', 'assignee_user_id' => null, 'assignee_name' => null, 'updated_at' => null, 'resolved_at' => null];
}
$value = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['handling'] ?? null;
abort_unless(is_array($value) && is_int($value['version'] ?? null) && $value['version'] > 0 && in_array($value['status'] ?? null, ['open', 'in_progress', 'resolved'], true)
&& array_key_exists('assignee_user_id', $value) && ($value['assignee_user_id'] === null || (is_int($value['assignee_user_id']) && $value['assignee_user_id'] > 0))
&& is_string($value['updated_at'] ?? null) && array_key_exists('resolved_at', $value) && ($value['resolved_at'] === null || is_string($value['resolved_at'])), 503, 'O histórico técnico não pôde ser identificado com segurança.');
return ['version' => $value['version'], 'status' => $value['status'], 'assignee_user_id' => $value['assignee_user_id'],
'assignee_name' => $value['assignee_user_id'] ? User::whereKey($value['assignee_user_id'])->value('name') : null, 'updated_at' => $value['updated_at'], 'resolved_at' => $value['resolved_at']];
}
/** @return array<string,mixed> */
public function detail(User $actor, string $reference): array
{
$root = app(OpportunityTechnicalSupport::class)->root($actor, $reference);
$data = json_decode($root->after_data, true, 512, JSON_THROW_ON_ERROR)['incident'];
$commands = [];
foreach ($this->events($reference)->where('actor_user_id', $actor->id)->selectRaw("after_data->'handling'->>'operation_key' AS key,after_data->'handling'->'version' AS version")->get() as $event) {
$version = is_string($event->version) ? json_decode($event->version, true) : $event->version;
if (is_string($event->key) && Str::isUuid($event->key) && is_int($version) && $version > 0) {
$commands[] = ['key' => $event->key, 'version' => $version];
}
}
$history = $this->events($reference)->leftJoin('users', 'users.id', '=', 'audit_events.actor_user_id')
->orderByRaw("(after_data->'handling'->'version') DESC NULLS FIRST")->orderByDesc('audit_events.id')->paginate(10, ['audit_events.id', 'audit_events.occurred_at', 'audit_events.after_data', 'users.name as actor'], 'history_page')->withQueryString()
->through(function ($event) {
$data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['handling'] ?? [];
$note = null;
try {
if (is_string($data['note'] ?? null)) {
$note = Crypt::decryptString($data['note']);
}
} catch (DecryptException) {/* Preserve encrypted original. */
}
return ['id' => $event->id, 'version' => $data['version'] ?? null, 'action' => $data['command'] ?? null, 'status' => $data['status'] ?? null, 'actor' => $event->actor, 'occurred_at' => $event->occurred_at, 'note' => $note, 'note_available' => $note !== null];
})->toArray();
return ['incident' => ['reference' => $reference, 'operation' => $data['operation'], 'observed_version' => $data['observed_version'], 'occurred_at' => $root->occurred_at, 'error_code' => $data['error_code'], 'handling' => $this->stateFor($reference)], 'history' => $history, 'commands' => $commands, 'actorId' => $actor->id];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function decide(User $actor, string $reference, array $input): array
{
abort_unless(app(OpportunityTechnicalSupport::class)->allows($actor), 403);
$data = Validator::make($input, ['operation_key' => 'required|uuid', 'version' => 'required|integer|min:0', 'action' => 'required|in:claim,release,note,resolve,reopen', 'note' => 'required|string|min:10|max:3000'])->validate();
$key = strtolower($data['operation_key']);
$note = trim($data['note']);
Validator::make(['note' => $note], ['note' => 'required|string|min:10|max:3000'])->validate();
$hash = hash('sha256', json_encode(['version' => (int) $data['version'], 'action' => $data['action'], 'note' => $note], JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $reference, $data, $key, $note, $hash) {
app(OpportunityTechnicalSupport::class)->root($actor, $reference, true);
$receipt = $this->events($reference)->where('actor_user_id', $actor->id)->whereRaw("after_data->'handling'->>'operation_key' = ?", [$key])->first(['after_data']);
if ($receipt) {
$old = json_decode($receipt->after_data, true, 512, JSON_THROW_ON_ERROR)['handling'];
abort_unless(($old['hash'] ?? null) === $hash, 409, 'Esta tentativa já foi utilizada com outros dados.');
return ['reference' => $reference, 'actor_id' => $actor->id, 'operation_key' => $key, 'result_version' => $old['version'], 'replayed' => true, 'handling' => $this->stateFor($reference)];
}
$before = $this->stateFor($reference);
abort_unless($before['version'] === (int) $data['version'], 409, 'O incidente mudou. Compare o estado actual antes de continuar.');
$after = $before;
$action = $data['action'];
if ($action === 'claim') {
abort_if($before['status'] === 'resolved', 422, 'Reabra o incidente antes de assumir o tratamento.');
abort_if($before['status'] === 'in_progress' && $before['assignee_user_id'] === $actor->id, 422, 'O tratamento já está assumido por si.');
$after['status'] = 'in_progress';
$after['assignee_user_id'] = $actor->id;
} elseif ($action === 'release') {
abort_unless($before['status'] === 'in_progress', 422, 'Só um tratamento em curso pode regressar à fila.');
$after['status'] = 'open';
$after['assignee_user_id'] = null;
} elseif ($action === 'resolve') {
abort_if($before['status'] === 'resolved', 422, 'O incidente já está resolvido.');
$after['status'] = 'resolved';
$after['resolved_at'] = now()->toIso8601String();
} elseif ($action === 'reopen') {
abort_unless($before['status'] === 'resolved', 422, 'Só um incidente resolvido pode ser reaberto.');
$after['status'] = 'open';
$after['assignee_user_id'] = null;
$after['resolved_at'] = null;
}
$after['version']++;
$after['updated_at'] = now()->toIso8601String();
unset($after['assignee_name']);
DB::table('audit_events')->insert(['id' => (string) Str::uuid(), 'correlation_id' => $reference, 'source_system' => self::SOURCE, 'entity_type' => 'opportunity-technical-incident', 'entity_id' => $reference, 'actor_user_id' => $actor->id, 'action' => 'incident_handled', 'data_classification' => 'restricted',
'before_data' => json_encode(['handling' => array_diff_key($before, ['assignee_name' => true])], JSON_THROW_ON_ERROR), 'after_data' => json_encode(['handling' => [...$after, 'operation_key' => $key, 'hash' => $hash, 'command' => $action, 'note' => Crypt::encryptString($note)]], JSON_THROW_ON_ERROR)]);
return ['reference' => $reference, 'actor_id' => $actor->id, 'operation_key' => $key, 'result_version' => $after['version'], 'replayed' => false, 'handling' => $this->stateFor($reference)];
});
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** One schema interpretation shared by the installer and runtime capability. */
final class TechnicalSupportProfileSchema
{
public const MIGRATION = '2026_11_03_080000_add_technical_support_profile';
/** @return array{has_table:bool,ledger:bool,state:string,installed:bool,consistent:bool,repairable:bool,fingerprint:string} */
public function inspect(): array
{
$table = Schema::hasTable('portal_role_assignments');
$ledger = Schema::hasTable('migrations') && DB::table('migrations')->where('migration', self::MIGRATION)->exists();
$constraint = $table ? DB::selectOne("SELECT pg_get_constraintdef(c.oid) AS definition,c.convalidated AS validated FROM pg_constraint c JOIN pg_class t ON t.oid=c.conrelid JOIN pg_namespace n ON n.oid=t.relnamespace WHERE n.nspname='public' AND t.relname='portal_role_assignments' AND c.conname='portal_role_name' AND c.contype='c'") : null;
$state = $table ? 'unrecognised' : 'absent';
if ($constraint && $constraint->validated === true && is_string($constraint->definition)) {
preg_match_all("/'([^']+)'/", $constraint->definition, $matches);
$roles = $matches[1];
sort($roles);
$shape = preg_replace(["/'[a-z_]+'/", '/::character varying(?:\[\])?/', '/::text(?:\[\])?/', '/\s+/', '/[()]/'], ['?', '', '', '', ''], $constraint->definition);
$base = ['auditor', 'promoter', 'publisher', 'validator'];
$support = ['auditor', 'promoter', 'publisher', 'technical_support', 'validator'];
if ($shape === 'CHECKrole=ANYARRAY['.implode(',', array_fill(0, count($roles), '?')).']') {
if ($roles === $base) {
$state = 'base';
} elseif ($roles === $support) {
$state = 'support';
}
}
}
return ['has_table' => $table, 'ledger' => $ledger, 'state' => $state, 'installed' => $ledger && $state === 'support',
'consistent' => (! $table && ! $ledger) || ($state === 'base' && ! $ledger) || ($state === 'support' && $ledger), 'repairable' => $ledger && $state === 'base',
'fingerprint' => hash('sha256', json_encode([$constraint?->definition, $constraint?->validated], JSON_THROW_ON_ERROR))];
}
}

Some files were not shown because too many files have changed in this diff Show More