Files
Investidor-V2/app/Http/Controllers/PortalServicesController.php
T
nala 250ac35fd1
tests / ci (push) Canceled after 0s
first commit
2026-09-28 10:01:09 +02:00

202 lines
12 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\InterestRequest;
use App\Models\Sector;
use App\Services\IdempotencyConflict;
use App\Services\InterestSubmission;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PortalServicesController extends Controller
{
public function home(): Response
{
return Inertia::render('PortalInvestidor', ['essentialResources' => $this->resources()->where('r.essential', true)
->orderBy('r.title')->limit(6)->get(['r.id', 'r.title'])]);
}
private function resources(): Builder
{
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.published', true)->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at');
}
public function page(Request $request): Response
{
$page = $request->route()->defaults['page'];
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:80'],
'topic' => ['nullable', 'string', 'max:80'], 'sector' => ['nullable', 'string', 'max:150'], 'province' => ['nullable', 'string', 'max:100']]);
$props = ['section' => $page, 'filters' => $filters];
if ($page === 'sectors') {
$props['sectors'] = Sector::where('is_active', true)->withCount(['opportunities' => fn ($q) => $q->published()])->orderBy('sort_order')->get(['id', 'name', 'slug', 'short_description']);
}
if (in_array($page, ['documents', 'legislation'], true)) {
$query = $this->resources();
if ($page === 'legislation') {
$query->where('r.legislation', true);
}
$props['categories'] = (clone $query)->distinct()->orderBy('r.category')->pluck('r.category');
$props['topics'] = (clone $query)->distinct()->orderBy('r.topic')->pluck('r.topic');
foreach (['category', 'topic'] as $field) {
if (! empty($filters[$field])) {
$query->where('r.'.$field, $filters[$field]);
}
}
if (! empty($filters['q'])) {
$query->where('r.title', 'ilike', '%'.$filters['q'].'%');
}
$props['resources'] = $query->orderByDesc('r.essential')->orderBy('r.title')->select(['r.id', 'r.title', 'r.category', 'r.topic', 'r.source_reference', 'r.essential'])->paginate(20)->withQueryString();
}
if ($page === 'partnerships') {
$query = DB::table('portal_partnerships')->where('status', 'published');
foreach (['sector', 'province'] as $field) {
if (! empty($filters[$field])) {
$query->where($field, $filters[$field]);
}
}
if (! empty($filters['q'])) {
$query->where('title', 'ilike', '%'.$filters['q'].'%');
}
$props['partnerships'] = $query->orderByDesc('id')->select(['id', 'title', 'sector', 'province', 'offer', 'seeking'])->paginate(20)->withQueryString();
}
if ($page === 'desk') {
$props['requests'] = InterestRequest::where('metadata->portal_user_id', $request->user()->id)->latest()->limit(100)->get(['reference', 'status', 'created_at', 'metadata']);
$props['myPartnerships'] = DB::table('portal_partnerships')->where('user_id', $request->user()->id)->latest()->limit(100)->get(['id', 'title', 'status']);
}
$props['contacts'] = config('portal.support');
return Inertia::render('portal/services', $props);
}
public function submit(Request $request, InterestSubmission $submission): RedirectResponse
{
$data = $request->validate([
'kind' => ['required', 'in:support,investor,company,partnership'],
'fullName' => ['required', 'string', 'min:3', 'max:150'], 'email' => ['required', 'email:rfc', 'max:150'],
'phone' => ['nullable', 'string', 'max:30'], 'country' => ['required', 'string', 'max:100'],
'company' => ['nullable', 'required_if:kind,company', 'string', 'max:150'],
'sectorInterest' => ['required', 'string', 'max:150'], 'message' => ['required', 'string', 'min:20', 'max:2000'],
'privacyConsent' => ['required', 'accepted'], 'submission_key' => ['required', 'uuid'],
'partnership_id' => ['nullable', 'integer'],
]);
if (! empty($data['partnership_id'])) {
abort_unless(DB::table('portal_partnerships')->where('id', $data['partnership_id'])->where('status', 'published')->exists(), 422);
}
$data['portal_user_id'] = $request->user()?->id;
try {
$interest = $submission->receive($data, $data['submission_key'], fn () => InterestRequest::create([
'reference' => 'AP-'.now()->year.'-'.Str::ulid(), 'full_name' => $data['fullName'], 'email' => $data['email'],
'phone' => $data['phone'] ?? null, 'country' => $data['country'],
'investor_type' => $data['kind'] === 'company' ? 'empresa' : 'individual',
'sector_interest' => $data['sectorInterest'], 'message' => $data['message'],
'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/apoio',
'metadata' => ['kind' => $data['kind'], 'company' => $data['company'] ?? null,
'portal_user_id' => $data['portal_user_id'], 'partnership_id' => $data['partnership_id'] ?? null],
]));
} catch (IdempotencyConflict) {
abort(409, 'Este envio já foi usado com dados diferentes. Recarregue o formulário.');
}
return back()->with('success', 'Pedido registado. Protocolo: '.$interest->reference);
}
public function partnership(Request $request): RedirectResponse
{
$data = $request->validate(['title' => ['required', 'string', 'max:200'], 'sector' => ['required', 'string', 'max:150'],
'province' => ['required', 'string', 'max:100'], 'offer' => ['required', 'string', 'min:20', 'max:2000'],
'seeking' => ['required', 'string', 'min:20', 'max:2000'], 'publication_consent' => ['accepted']]);
unset($data['publication_consent']);
DB::table('portal_partnerships')->insert([...$data, 'user_id' => $request->user()->id, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
return back()->with('success', 'Proposta recebida para publicação. Acompanhe no Balcão Virtual.');
}
public function download(int $id): BinaryFileResponse
{
$resource = $this->resources()->where('r.id', $id)->first(['d.storage_key', 'd.file_name', 'd.sha256']);
abort_unless($resource !== null, 404);
$root = realpath(Storage::disk('local')->path(''));
$path = realpath(Storage::disk('local')->path($resource->storage_key));
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
$checksum = hash_file('sha256', $path);
abort_unless($checksum !== false && hash_equals($resource->sha256, $checksum), 404);
return response()->download($path, basename($resource->file_name), ['X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, no-store']);
}
public function manage(): Response
{
return Inertia::render('portal/manage', ['resources' => DB::table('portal_resources')->latest()->limit(100)->get(),
'partnerships' => DB::table('portal_partnerships')->latest()->limit(100)->get(),
'documents' => DB::table('documents')->where('status', 'active')->where('data_classification', 'public')->whereNull('deleted_at')->get(['id', 'file_name'])]);
}
public function upload(Request $request): RedirectResponse
{
$request->validate(['file' => ['required', 'file', 'mimetypes:application/pdf,text/plain', 'max:10240'], 'publication_consent' => ['accepted']]);
$file = $request->file('file');
$id = (string) Str::uuid();
$key = $file->storeAs('portal-documents', $id, 'local');
abort_unless($key !== false, 500);
try {
DB::transaction(function () use ($file, $id, $key, $request) {
DB::table('documents')->insert(['id' => $id, 'source_system' => 'portal-services',
'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $key,
'sha256' => hash_file('sha256', Storage::disk('local')->path($key)), 'status' => 'active', 'data_classification' => 'public']);
DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => 'documents', 'entity_id' => $id,
'actor_user_id' => $request->user()->id, 'action' => 'public_document_uploaded']);
});
} catch (\Throwable $exception) {
Storage::disk('local')->delete($key);
throw $exception;
}
return back()->with('success', 'Documento carregado. Seleccione-o no catálogo e registe a fonte para publicar.');
}
public function resource(Request $request): RedirectResponse
{
$data = $request->validate(['document_id' => ['required', 'uuid'], 'title' => ['required', 'string', 'max:200'],
'category' => ['required', 'string', 'max:80'], 'topic' => ['required', 'string', 'max:80'],
'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'legislation' => ['required', 'boolean'], 'essential' => ['required', 'boolean'], 'published' => ['required', 'boolean']]);
DB::transaction(function () use ($data, $request) {
abort_unless(DB::table('documents')->where('id', $data['document_id'])->where('status', 'active')->where('data_classification', 'public')->whereNull('deleted_at')->lockForUpdate()->exists(), 422, 'Seleccione um documento público activo.');
$id = DB::table('portal_resources')->insertGetId([...$data, 'approved_by' => $request->user()->id, 'created_at' => now(), 'updated_at' => now()]);
$this->audit($request, 'resource_registered', 'portal_resources', $id);
});
return back()->with('success', 'Documento catalogado.');
}
public function moderate(Request $request, string $type, int $id): RedirectResponse
{
$data = $request->validate(['published' => ['required', 'boolean']]);
$table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
DB::transaction(function () use ($request, $data, $table, $type, $id) {
abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404);
DB::table($table)->where('id', $id)->update($type === 'resources'
? ['published' => $data['published'], 'approved_by' => $request->user()->id, 'updated_at' => now()]
: ['status' => $data['published'] ? 'published' : 'withdrawn', 'reviewed_by' => $request->user()->id, 'updated_at' => now()]);
$this->audit($request, $data['published'] ? 'published' : 'withdrawn', $table, $id);
});
return back()->with('success', 'Publicação actualizada.');
}
private function audit(Request $request, string $action, string $table, int $id): void
{
DB::table('audit_events')->insert(['source_system' => 'portal-services', 'entity_type' => $table,
'actor_user_id' => $request->user()->id, 'action' => $action, 'after_data' => json_encode(['record_id' => $id], JSON_THROW_ON_ERROR)]);
}
}