202 lines
12 KiB
PHP
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)]);
|
|
}
|
|
}
|