novas funcionalidades dos formulario
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-02 11:13:37 +02:00
parent a6afb4e97a
commit 4f35313d06
104 changed files with 7653 additions and 178 deletions
@@ -2,13 +2,16 @@
namespace App\Http\Controllers\Api\V1;
use App\Domain\Interests\Enums\InterestStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreInterestRequestRequest;
use App\Jobs\NotifyInterestReceived;
use App\Models\ExistingBusiness;
use App\Models\InterestRequest;
use App\Models\Opportunity;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
@@ -33,8 +36,17 @@ class InterestRequestController extends Controller
throw ValidationException::withMessages(['opportunityReference' => 'A oportunidade seleccionada já não está disponível.']);
}
$business = ! empty($validated['existingBusinessId'])
? ExistingBusiness::published()->whereKey($validated['existingBusinessId'])->first()
: null;
if (! empty($validated['existingBusinessId']) && ! $business) {
throw ValidationException::withMessages(['existingBusinessId' => 'A oportunidade seleccionada já não está disponível.']);
}
$interest = InterestRequest::create([
'opportunity_id' => $opportunity?->id,
'existing_business_id' => $business?->id,
'modality' => $business ? $validated['modality'] : null,
'reference' => $this->generateReference(),
'full_name' => trim($validated['fullName']),
'email' => strtolower(trim($validated['email'])),
@@ -47,7 +59,7 @@ class InterestRequestController extends Controller
'message' => trim($validated['message']),
'privacy_consent' => (bool) $validated['privacyConsent'],
'consented_at' => now(),
'status' => 'received',
'status' => InterestStatus::Received->value,
'source_page' => Str::limit($request->headers->get('referer', ''), 255, ''),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
@@ -78,6 +90,12 @@ class InterestRequestController extends Controller
} catch (IdempotencyConflict) {
return response()->json(['success' => false, 'message' => 'A chave de envio já foi usada com dados diferentes.'], 409);
} catch (ValidationException $exception) {
throw $exception;
} catch (QueryException $exception) {
if ($exception->getCode() === '23505') {
return response()->json(['success' => false, 'message' => 'Já existe uma manifestação activa para esta oportunidade e modalidade.'], 409);
}
throw $exception;
} catch (\Throwable $exception) {
Log::error('Erro ao registar manifestação de interesse', [
@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\InvestmentLegalSource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class LegislationSourceController extends Controller
{
public function index(Request $request): JsonResponse
{
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:100'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$today = today()->toDateString();
$query = InvestmentLegalSource::query()
->where('validation_status', 'validated')
->whereDate('valid_from', '<=', $today)
->where(fn ($query) => $query->whereNull('valid_until')->orWhereDate('valid_until', '>=', $today));
if (! empty($filters['q'])) {
$term = '%'.addcslashes($filters['q'], '%_\\').'%';
$query->where(fn ($query) => $query
->whereRaw('title ILIKE ?', [$term])
->orWhereRaw('diploma ILIKE ?', [$term])
->orWhereRaw('article ILIKE ?', [$term])
->orWhereRaw('competent_entity ILIKE ?', [$term]));
}
$page = $query->orderBy('title')->orderBy('id')->paginate($filters['per_page'] ?? 25)->withQueryString();
$data = collect($page->items())->map(fn (InvestmentLegalSource $source): array => [
'id' => $source->id,
'title' => $source->title,
'url' => $source->url,
'diploma' => $source->diploma,
'article' => $source->article,
'competent_entity' => $source->competent_entity,
'validation_status' => $source->validation_status,
'version_no' => (int) $source->version_no,
'valid_from' => $source->valid_from,
'valid_until' => $source->valid_until,
'validator_id' => $source->validator_id === null ? null : (int) $source->validator_id,
'validated_at' => $source->validated_at,
])->values();
$body = [
'data' => $data,
'meta' => [
'current_page' => $page->currentPage(),
'per_page' => $page->perPage(),
'total' => $page->total(),
],
];
$etag = 'W/"legal-sources-'.hash('sha256', json_encode([
'filters' => $filters,
'data' => $data,
'total' => $page->total(),
], JSON_THROW_ON_ERROR)).'"';
return response()->json($body)
->header('ETag', $etag)
->header('Cache-Control', 'public, max-age=300');
}
}
@@ -0,0 +1,187 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ReferenceCatalogController extends Controller
{
private const CATALOGS = ['authorities', 'jurisdictions', 'economic-activities'];
public function index(Request $request, string $catalog): JsonResponse
{
abort_unless(in_array($catalog, self::CATALOGS, true), 404);
$rules = [
'q' => ['nullable', 'string', 'max:100'],
'version' => ['nullable', 'integer', 'min:1'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'parent_id' => ['nullable', 'uuid'],
'jurisdiction_id' => ['nullable', 'uuid'],
];
if ($catalog === 'jurisdictions') {
$rules['type'] = ['required', 'in:country,province,district,admin_post,locality'];
$rules['parent_id'][] = 'prohibited_if:type,country';
$rules['parent_id'][] = 'required_if:type,district,admin_post,locality';
}
if ($catalog === 'economic-activities') {
$rules['sector_id'] = ['nullable', 'integer', 'exists:sectors,id'];
}
$data = $request->validate($rules);
$versionQuery = DB::table('reference_catalog_versions')->where('catalog_code', $catalog)->where('locale', 'pt-MZ');
if (isset($data['version'])) {
$versionQuery->where('version_no', $data['version'])->whereIn('status', ['published', 'retired']);
} else {
$versionQuery->where('status', 'published')
->where(fn (Builder $query) => $query->whereNull('valid_from')->orWhere('valid_from', '<=', today()->toDateString()))
->where(fn (Builder $query) => $query->whereNull('valid_to')->orWhere('valid_to', '>', today()->toDateString()));
}
$version = $versionQuery->orderByDesc('version_no')->first();
if (isset($data['version']) && ! $version) {
abort(404, 'Versão publicada do catálogo não encontrada.');
}
if (! $version) {
return response()->json([
'data' => [],
'links' => ['next' => null, 'previous' => null],
'next_page_url' => null,
'prev_page_url' => null,
'current_page' => 1,
'per_page' => $data['per_page'] ?? 20,
'total' => 0,
'last_page' => 1,
'meta' => ['catalog' => $catalog, 'version' => null, 'locale' => 'pt-MZ', 'status' => 'not_published',
'current_page' => 1, 'per_page' => $data['per_page'] ?? 20, 'total' => 0, 'last_page' => 1],
])->header('Cache-Control', 'public, max-age=60');
}
$query = match ($catalog) {
'authorities' => $this->authorities((string) $version->id, $data),
'jurisdictions' => $this->jurisdictions($data),
'economic-activities' => $this->economicActivities((string) $version->id, $data),
};
$page = $query->orderBy('code')->orderBy('id')->paginate($data['per_page'] ?? 20)->withQueryString();
$items = collect($page->items())->map(fn (object $row): array => $this->present($catalog, $row))->values();
return response()->json([
'data' => $items,
'links' => ['next' => $page->nextPageUrl(), 'previous' => $page->previousPageUrl()],
'next_page_url' => $page->nextPageUrl(),
'prev_page_url' => $page->previousPageUrl(),
'current_page' => $page->currentPage(),
'per_page' => $page->perPage(),
'total' => $page->total(),
'last_page' => $page->lastPage(),
'meta' => ['catalog' => $catalog, 'version' => (int) $version->version_no, 'locale' => $version->locale,
'source_version' => $version->source_version, 'valid_from' => $version->valid_from, 'valid_to' => $version->valid_to,
'current_page' => $page->currentPage(), 'per_page' => $page->perPage(), 'total' => $page->total(), 'last_page' => $page->lastPage()],
])->header('Cache-Control', 'public, max-age=300')
->header('ETag', 'W/"catalog-'.substr(hash('sha256', $catalog.'|'.$version->version_no.'|'.json_encode($data, JSON_THROW_ON_ERROR)), 0, 32).'"');
}
/** @param array<string,mixed> $data */
private function authorities(string $versionId, array $data): Builder
{
$query = DB::table('authorities as a')->join('organizations as o', 'o.id', '=', 'a.organization_id')
->leftJoin('geo_locations as g', 'g.id', '=', 'a.jurisdiction_id')
->where('a.catalog_version_id', $versionId)->where('a.status', 'published')
->where('o.data_classification', 'public')->whereNull('o.deleted_at')
->where('a.valid_from', '<=', today()->toDateString())
->where(fn (Builder $builder) => $builder->whereNull('a.valid_to')->orWhere('a.valid_to', '>', today()->toDateString()))
->select(['a.id', 'a.code', 'a.authority_type', 'a.label_pt_mz as name', 'a.organization_id', 'a.jurisdiction_id',
'g.name as jurisdiction_name', 'a.version_no']);
if (! empty($data['jurisdiction_id'])) {
$query->where('a.jurisdiction_id', $data['jurisdiction_id']);
}
if (! empty($data['q'])) {
$this->search($query, $data['q'], ['a.code', 'a.label_pt_mz', 'o.name']);
}
return $query;
}
/** @param array<string,mixed> $data */
private function jurisdictions(array $data): Builder
{
$query = DB::table('geo_locations')->where('source_system', 'cartografia-workbook')
->where('data_classification', 'public')->whereNull('deleted_at')->where('location_type', $data['type'])
->select(['id', 'code', 'name', 'parent_id', 'location_type', 'source_code', 'version_no']);
$parentType = ['province' => 'country', 'district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']] ?? null;
if ($data['type'] !== 'country' && ! empty($data['parent_id'])) {
$validParent = DB::table('geo_locations')->where('id', $data['parent_id'])->where('location_type', $parentType)
->where('source_system', 'cartografia-workbook')->where('data_classification', 'public')->whereNull('deleted_at')->exists();
if (! $validParent) {
throw ValidationException::withMessages(['parent_id' => 'Seleccione um território pai activo do nível anterior.']);
}
$query->where('parent_id', $data['parent_id']);
} elseif (in_array($data['type'], ['district', 'admin_post', 'locality'], true)) {
throw ValidationException::withMessages(['parent_id' => 'Seleccione primeiro o território pai.']);
}
if (! empty($data['q'])) {
$this->search($query, $data['q'], ['code', 'name', 'source_code']);
}
return $query;
}
/** @param array<string,mixed> $data */
private function economicActivities(string $versionId, array $data): Builder
{
$query = DB::table('economic_activities')->where('catalog_version_id', $versionId)->where('status', 'published')
->where('valid_from', '<=', today()->toDateString())
->where(fn (Builder $builder) => $builder->whereNull('valid_to')->orWhere('valid_to', '>', today()->toDateString()))
->select(['id', 'code', 'label_pt_mz as name', 'parent_id', 'sector_id', 'version_no']);
if (array_key_exists('parent_id', $data) && $data['parent_id'] !== null) {
$query->where('parent_id', $data['parent_id']);
} else {
$query->whereNull('parent_id');
}
if (! empty($data['sector_id'])) {
$query->where('sector_id', $data['sector_id']);
}
if (! empty($data['q'])) {
$this->search($query, $data['q'], ['code', 'label_pt_mz']);
}
return $query;
}
/** @param list<string> $columns */
private function search(Builder $query, string $search, array $columns): void
{
$term = '%'.addcslashes($search, '%_\\').'%';
$query->where(function (Builder $nested) use ($columns, $term): void {
foreach ($columns as $index => $column) {
$method = $index === 0 ? 'whereRaw' : 'orWhereRaw';
$nested->{$method}($column.' ILIKE ?', [$term]);
}
});
}
/** @return array<string,mixed> */
private function present(string $catalog, object $row): array
{
return match ($catalog) {
'jurisdictions' => ['id' => $row->id, 'value' => $row->id, 'code' => $row->code, 'name' => $row->name,
'label' => $row->name, 'locale' => 'pt-MZ', 'parent_id' => $row->parent_id, 'type' => $row->location_type,
'source_code' => $row->source_code, 'version_no' => $row->version_no],
'economic-activities' => ['id' => $row->id, 'value' => $row->code, 'code' => $row->code, 'name' => $row->name,
'label' => $row->name, 'locale' => 'pt-MZ', 'parent_id' => $row->parent_id, 'sector_id' => $row->sector_id,
'version_no' => $row->version_no],
'authorities' => ['id' => $row->id, 'value' => $row->code, 'code' => $row->code, 'name' => $row->name,
'label' => $row->name, 'locale' => 'pt-MZ', 'authority_type' => $row->authority_type,
'organization_id' => $row->organization_id, 'jurisdiction_id' => $row->jurisdiction_id,
'jurisdiction_name' => $row->jurisdiction_name, 'version_no' => $row->version_no],
};
}
}
@@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSimulationRequest;
use App\Http\Requests\UpdateSimulationScenarioRequest;
use App\Models\QuestionnaireVersion;
use App\Models\SimulationJob;
use App\Policies\SimulationPolicy;
use App\Services\SimulationExecution;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class SimulationController extends Controller
{
public function questionnaire(string $code): JsonResponse
{
abort_unless(preg_match('/^[a-z0-9][a-z0-9-]{0,79}$/', $code) === 1, 404);
$version = QuestionnaireVersion::query()->where('code', $code)->where('locale', 'pt-MZ')->where('status', 'published')
->where(fn ($query) => $query->whereNull('valid_from')->orWhereDate('valid_from', '<=', today()))
->where(fn ($query) => $query->whereNull('valid_to')->orWhereDate('valid_to', '>', today()))
->orderByDesc('version_no')->firstOrFail();
$etag = '"questionnaire-'.$version->id.'-v'.$version->version_no.'"';
return response()->json(['data' => $version])->header('ETag', $etag)->header('Cache-Control', 'private, max-age=60');
}
public function store(StoreSimulationRequest $request, SimulationExecution $execution): JsonResponse
{
$key = $request->header('Idempotency-Key');
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
$actorId = $request->user()?->id;
$token = $actorId === null ? $request->header('X-Simulation-Token') : null;
abort_if($actorId === null && (! is_string($token) || strlen($token) < 32 || strlen($token) > 256), 422, 'X-Simulation-Token opaco gerado pelo cliente é obrigatório para execução anónima.');
$validated = $request->validated();
$data = ['base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
'reduction' => (string) ($validated['reduction'] ?? '0'), 'organization_id' => $validated['organization_id'] ?? null];
$organizationId = $data['organization_id'] ?? null;
$execution->authorizeOrganization($actorId, $organizationId);
$result = $execution->create($data, $actorId, $organizationId, $token, $key);
$simulation = $result['simulation'];
$body = ['data' => $simulation, 'replayed' => $result['replayed']];
if ($actorId === null) {
$body['access_token'] = $token;
}
return response()->json($body, $result['replayed'] ? 200 : 202)
->header('ETag', $execution->etag($simulation))
->header('Location', url('/api/v1/simulations/'.$simulation->id));
}
public function show(Request $request, string $id, SimulationExecution $execution): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$simulation = $execution->find($id, $request);
if ($request->user()) {
abort_unless(app(SimulationPolicy::class)->view($request->user(), $simulation), 404);
} else {
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
}
return response()->json(['data' => $simulation])->header('ETag', $execution->etag($simulation));
}
public function updateScenario(UpdateSimulationScenarioRequest $request, string $id, string $scenarioId, SimulationExecution $execution): JsonResponse
{
abort_unless(Str::isUuid($id) && Str::isUuid($scenarioId), 404);
$simulation = $execution->find($id, $request);
if ($request->user()) {
abort_unless(app(SimulationPolicy::class)->update($request->user(), $simulation), 403);
} else {
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
}
$validated = $request->validated();
$data = ['label' => (string) $validated['label'], 'base' => (string) $validated['base'], 'rate' => (string) $validated['rate'],
'reduction' => (string) ($validated['reduction'] ?? '0')];
$updated = $execution->updateScenario($id, $scenarioId, $data, $request);
return response()->json(['data' => $updated], 202)->header('ETag', $execution->etag($updated));
}
public function report(Request $request, string $id, string $reportId, SimulationExecution $execution): JsonResponse
{
abort_unless(Str::isUuid($id) && Str::isUuid($reportId), 404);
$simulation = $execution->find($id, $request);
if ($request->user()) {
abort_unless(app(SimulationPolicy::class)->view($request->user(), $simulation), 404);
} else {
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
}
$report = $simulation->reports->firstWhere('id', $reportId);
abort_if($report === null, 404);
return response()->json(['data' => $report]);
}
public function jobs(Request $request, string $id, SimulationExecution $execution): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$simulation = $execution->find($id, $request);
if ($request->user()) {
abort_unless(app(SimulationPolicy::class)->view($request->user(), $simulation), 404);
} else {
abort_unless($simulation->anonymous_token_hash !== null && hash_equals($simulation->anonymous_token_hash, (string) $execution->tokenHash($request)), 404);
}
$jobs = DB::transaction(function () use ($request, $id, $execution) {
$execution->setContext($request->user()?->id, $execution->tokenHash($request));
return SimulationJob::query()->where('simulation_id', $id)
->orderBy('created_at')->orderBy('id')->get();
});
return response()->json(['data' => $jobs]);
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSimulationFormDraftRequest;
use App\Models\SimulationFormDraft;
use App\Services\SimulationFormDrafts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class SimulationFormDraftController extends Controller
{
public function store(StoreSimulationFormDraftRequest $request, SimulationFormDrafts $drafts): JsonResponse
{
$key = $request->header('Idempotency-Key');
abort_unless(is_string($key) && strlen($key) >= 16 && strlen($key) <= 128, 428, 'Idempotency-Key é obrigatório.');
$result = $drafts->create($request->validated(), $request, $key);
$draft = $result['draft'];
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no, 'replayed' => $result['replayed']]], $result['replayed'] ? 200 : 201)
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function current(Request $request, SimulationFormDrafts $drafts): JsonResponse
{
$draft = $drafts->current($request);
if (! $draft) {
return response()->json(['data' => null], 200)->header('Cache-Control', 'no-store, private');
}
return response()->json(['data' => $drafts->present($draft)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function show(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$draft = $drafts->find($id, $request);
return response()->json(['data' => $drafts->present($draft)])->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function update(StoreSimulationFormDraftRequest $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$draft = $drafts->update($id, $request->validated(), $request);
return response()->json(['data' => ['id' => $draft->id, 'version_no' => $draft->version_no]])
->header('ETag', $drafts->etag($draft))->header('Cache-Control', 'no-store, private');
}
public function destroy(Request $request, string $id, SimulationFormDrafts $drafts): JsonResponse
{
abort_unless(Str::isUuid($id), 404);
$drafts->delete($id, $request);
return response()->json([], 204)->header('Cache-Control', 'no-store, private');
}
}
@@ -28,7 +28,9 @@ class ExistingBusinessController extends Controller
$query = app(BusinessRepository::class)->query();
if ($management) {
Gate::authorize('reviewQueue', ExistingBusiness::class);
$query->where('owner_user_id', '<>', $request->user()->id)->whereIn('status', ['submitted', 'in_review', 'needs_information']);
$query->where('owner_user_id', '<>', $request->user()->id)->whereIn('status', [
'submitted', 'triage', 'in_review', 'needs_information', 'approval_l1', 'approval_l2', 'approved',
]);
} else {
$query->where('owner_user_id', $request->user()->id);
}
@@ -45,6 +47,10 @@ class ExistingBusinessController extends Controller
app(CompatibilityShadow::class)->schedule($id);
$props = ['sectors' => Sector::where('is_active', true)->orderBy('name')->get(['id', 'name']), 'formSchema' => BusinessFormSchema::fields(), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $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),
'canApproveLevelTwo' => $request->user()->can('approveLevelTwo', $business),
'canPublish' => $request->user()->can('publish', $business),
'modalities' => ExistingBusinessDrafts::MODALITIES,
'objectKinds' => ExistingBusinessObjects::KINDS,
'verificationChecks' => ExistingBusinessVerification::CHECKS,
@@ -90,6 +96,10 @@ class ExistingBusinessController extends Controller
/** @return array<string,mixed> */
private function record(ExistingBusiness $business): array
{
return [...$business->only(['id', 'code', 'modality', 'status', 'classification', 'version_no', 'sector_id']), 'payload' => $business->payload];
return [...$business->only([
'id', 'code', 'modality', 'status', 'classification', 'version_no', 'sector_id', 'public_title', 'public_slug',
'activity_code', 'stage', 'objective', 'currency', 'amount_min', 'amount_max', 'available_percentage',
'funds_use', 'submitted_at', 'published_at', 'expires_at', 'next_action', 'next_action_due_at', 'assigned_to',
]), 'payload' => $business->payload];
}
}
@@ -27,7 +27,8 @@ class InvestmentLegalSourceController extends Controller
$source->refresh();
$this->audit($source, $request->user()->id, 'created');
return response()->json($source->fresh(), 201);
return response()->json($source->fresh(), 201)
->header('Location', url('/api/v2/investment/admin/legal-sources/'.$source->id));
});
}
@@ -6,12 +6,16 @@ use App\Models\InterestRequest;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\InterestWorkflow;
use App\Services\InvestmentGeography;
use App\Services\InvestmentWorkspace;
use App\Services\PortalAudit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
@@ -22,7 +26,7 @@ use Inertia\Response;
class PortalManagementController extends Controller
{
private const STATES = ['received', 'in_review', 'contacted', 'closed'];
private const STATES = ['received', 'submitted', 'in_review', 'reviewed', 'qualification', 'qualified', 'contacted', 'contact_accepted', 'referred', 'due_diligence', 'negotiation', 'pending', 'refused', 'converted', 'cancelled', 'closed'];
public function history(): Response
{
@@ -50,9 +54,10 @@ class PortalManagementController extends Controller
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(self::STATES)],
'follow_up' => ['nullable', Rule::in(['overdue', 'upcoming', 'unassigned'])],
]);
$query = InterestRequest::query()->select([
'id', 'reference', 'full_name', 'email', 'sector_interest', 'status', 'created_at',
$query = InterestRequest::query()->with('assignee:id,name')->select([
'id', 'reference', 'full_name', 'email', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at',
]);
if ($term = $filters['q'] ?? null) {
$query->where(fn ($q) => $q->whereLike('reference', "%{$term}%")
@@ -61,6 +66,15 @@ class PortalManagementController extends Controller
if ($status = $filters['status'] ?? null) {
$query->where('status', $status);
}
if (($filters['follow_up'] ?? null) === 'overdue') {
$query->whereNotNull('next_action_due_at')->where('next_action_due_at', '<', now())
->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
} elseif (($filters['follow_up'] ?? null) === 'upcoming') {
$query->whereNotNull('next_action_due_at')->where('next_action_due_at', '>=', now())
->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
} elseif (($filters['follow_up'] ?? null) === 'unassigned') {
$query->whereNull('assigned_to')->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
}
return Inertia::render('management/interests', [
'requests' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
@@ -70,31 +84,94 @@ class PortalManagementController extends Controller
public function interest(InterestRequest $interest): Response
{
$history = DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)
->orderByDesc('created_at')->limit(50)->get(['from_status', 'to_status', 'actor_id', 'reason', 'created_at'])
->map(function ($row) {
$row->note = $row->reason ? Crypt::decryptString($row->reason) : null;
unset($row->reason);
return $row;
});
$consents = DB::table('consents')->where('interest_request_id', $interest->id)
->orderBy('created_at')->get(['purpose', 'policy_version', 'status', 'granted_at', 'withdrawn_at']);
return Inertia::render('management/interest', [
'interest' => $interest->only([
'id', 'reference', 'full_name', 'email', 'phone', 'investor_type', 'country',
'sector_interest', 'opportunity_reference', 'investment_range', 'message',
'privacy_consent', 'consented_at', 'status', 'created_at', 'reviewed_at', 'reviewed_by', 'metadata',
'existing_business_id', 'modality', 'assigned_to', 'next_action', 'next_action_due_at',
]),
'history' => $history,
'consents' => $consents,
'assignees' => $this->interestAssignees()->get(['id', 'name']),
]);
}
public function review(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): RedirectResponse|JsonResponse
{
$validated = $request->validate(['status' => ['required', Rule::in(self::STATES)]]);
DB::transaction(function () use ($request, $interest, $validated) {
$locked = InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail();
$changed = $locked->status !== $validated['status'];
PortalAudit::save($locked, [
...$validated, 'reviewed_at' => now(), 'reviewed_by' => $request->user()->id,
], $request->user()->id);
$submission = InvestmentSubmission::where('interest_request_id', $interest->id)->first();
if ($changed && $submission) {
app(InvestmentWorkspace::class)->notify($submission->user_id, $submission, 'status', (string) Str::uuid(), ['status' => $validated['status']]);
$validated = $request->validate([
'status' => ['required', Rule::in(self::STATES)],
'note' => ['nullable', 'string', 'min:10', 'max:2000'],
'next_action' => ['nullable', 'required_with:next_action_due_at', 'string', 'max:180'],
'next_action_due_at' => ['nullable', 'required_with:next_action', 'date', 'after_or_equal:today'],
'assigned_to' => ['nullable', 'integer', Rule::in($this->interestAssignees()->pluck('id'))],
]);
$before = $interest->status;
$updated = $workflow->transition($request->user(), $interest, $validated['status'], $validated['note'] ?? null,
$validated['next_action'] ?? null, $validated['next_action_due_at'] ?? null, isset($validated['assigned_to']) ? (int) $validated['assigned_to'] : null,
$request->exists('assigned_to'), $request->exists('next_action') || $request->exists('next_action_due_at'));
PortalAudit::save($updated, ['status' => $updated->status, 'reviewed_at' => $updated->reviewed_at, 'reviewed_by' => $updated->reviewed_by], $request->user()->id);
$submission = InvestmentSubmission::where('interest_request_id', $interest->id)->first();
if ($before !== $updated->status && $submission) {
app(InvestmentWorkspace::class)->notify($submission->user_id, $submission, 'status', (string) Str::uuid(), ['status' => $updated->status]);
}
return $request->expectsJson() ? response()->json(['reference' => $updated->reference, 'status' => $updated->status]) : back()->with('success', 'Estado do pedido actualizado.');
}
public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
DB::transaction(function () use ($request, $interest, $data) {
$consent = DB::table('consents')->where('interest_request_id', $interest->id)
->where('purpose', 'share_contact_with_proponent')->where('status', 'granted')->lockForUpdate()->first();
if (! $consent) {
return;
}
DB::table('consents')->where('id', $consent->id)->update(['status' => 'withdrawn', 'withdrawn_at' => now(), 'updated_at' => now()]);
$correlationId = (string) Str::uuid();
DB::table('business_workflow_transitions')->insert([
'id' => (string) Str::uuid(), 'interest_request_id' => $interest->id,
'from_status' => $interest->status, 'to_status' => $interest->status,
'actor_id' => $request->user()->id, 'reason' => Crypt::encryptString($data['reason']),
'correlation_id' => $correlationId,
'metadata' => json_encode(['source' => 'management-interest', 'action' => 'consent_withdrawn',
'purpose' => 'share_contact_with_proponent'], JSON_THROW_ON_ERROR),
'created_at' => now(),
]);
DB::table('outbox_events')->insertOrIgnore([
'source_system' => 'portal-intake', 'external_id' => 'InterestConsentWithdrawn:'.$consent->id,
'aggregate_type' => 'interest_consent', 'aggregate_id' => $consent->id,
'event_type' => 'InterestConsentWithdrawn', 'data_classification' => 'confidential',
'correlation_id' => $correlationId, 'status' => 'pending',
'payload' => json_encode(['schema_version' => 1, 'consent_id' => $consent->id,
'interest_id' => $interest->integration_id, 'purpose' => 'share_contact_with_proponent'], JSON_THROW_ON_ERROR),
]);
});
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'status' => $validated['status']]) : back()->with('success', 'Estado do pedido actualizado.');
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'sharing_consent' => 'withdrawn'])
: back()->with('success', 'Consentimento de partilha revogado.');
}
private function interestAssignees(): Builder
{
$emails = array_unique(array_merge(config('portal.managers', []), ...array_map(
fn (string $designation) => config('existing-business.'.$designation, []),
['triagers', 'reviewers', 'approvers_l1', 'approvers_l2', 'publishers'],
)));
return User::query()->whereNotNull('email_verified_at')->whereIn(DB::raw('lower(email)'), $emails)->orderBy('name');
}
public function catalog(Request $request, string $resource): Response
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\PublicBusinessOpportunityResource;
use App\Models\ExistingBusiness;
use App\Services\ExistingBusinessDrafts;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class PublicBusinessOpportunityController extends Controller
{
public function index(Request $request): Response|JsonResponse
{
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:120'],
'sector' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
]);
$query = $this->publishedQuery();
if (! empty($filters['q'])) {
$term = trim($filters['q']);
$query->where(fn (Builder $builder) => $builder->whereLike('existing_businesses.public_title', '%'.$term.'%')
->orWhereLike('existing_businesses.activity_code', '%'.$term.'%')
->orWhereLike('existing_businesses.objective', '%'.$term.'%'));
}
if (! empty($filters['sector'])) {
$query->where('existing_businesses.sector_id', $filters['sector']);
}
$results = PublicBusinessOpportunityResource::collection($query->paginate($filters['per_page'] ?? 12)->withQueryString());
if ($request->expectsJson()) {
return $results->response();
}
return Inertia::render('investment/business-marketplace', ['results' => $results, 'filters' => $filters]);
}
public function show(Request $request, string $slug): Response|JsonResponse
{
$record = $this->publishedQuery()->where('existing_businesses.public_slug', $slug)->firstOrFail();
$resource = new PublicBusinessOpportunityResource($record);
if ($request->expectsJson()) {
return $resource->response();
}
return Inertia::render('investment/business-opportunity', ['record' => $resource]);
}
public function interest(Request $request, string $slug): Response
{
$record = $this->publishedQuery()->where('existing_businesses.public_slug', $slug)->firstOrFail();
return Inertia::render('investment/business-interest', [
'record' => (new PublicBusinessOpportunityResource($record))->resolve($request),
'modalities' => ExistingBusinessDrafts::MODALITIES,
]);
}
/** @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)
->whereNull('province.deleted_at')->whereNull('district.deleted_at')->whereNull('locality.deleted_at')
->select('locations.existing_business_id', 'province.name as province')
->selectRaw("CASE WHEN locations.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')
->leftJoinSub($publicLocations, 'public_location', 'public_location.existing_business_id', '=', 'existing_businesses.id')
->select(['existing_businesses.id', 'existing_businesses.public_slug', 'existing_businesses.public_title',
'existing_businesses.activity_code', 'existing_businesses.stage', 'existing_businesses.objective',
'existing_businesses.currency', 'existing_businesses.amount_min', 'existing_businesses.amount_max',
'existing_businesses.available_percentage', 'existing_businesses.published_at', 'existing_businesses.sector_id',
'public_location.province', 'public_location.district', 'public_location.locality']);
}
}