Files
Investidor-V2/app/Http/Controllers/InvestmentSubmissionController.php

439 lines
32 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Jobs\ScanDocumentForMalware;
use App\Models\InterestRequest;
use App\Models\InvestmentSpecificNeed;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Services\CountryCatalogue;
use App\Services\InterestSubmission;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentConsumerScope;
use App\Services\InvestmentWorkspace;
use App\Services\InvestmentZones;
use App\Services\InvestorContextConsumers;
use App\Services\VirtualDesk;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class InvestmentSubmissionController extends Controller
{
public function index(Request $request): Response|JsonResponse
{
$query = InvestmentSubmission::where('user_id', $request->user()->id);
if (! $request->user()->hasVerifiedEmail()) {
// The existing payload is encrypted; scope after decryption, never infer ownership from email.
$simpleIds = InvestmentSubmission::where('user_id', $request->user()->id)->where('status', 'submitted')->get(['id', 'payload'])
->filter(fn ($row) => ($row->payload['simple_participation'] ?? false) === true)->modelKeys();
$query->whereIn('id', $simpleIds);
}
$submissions = $query->latest()->paginate(20)->through(function ($row) {
$interest = $row->interest_request_id ? InterestRequest::find($row->interest_request_id) : null;
return ['id' => $row->id, 'status' => $row->status, 'version' => $row->version, 'created_at' => $row->created_at,
'reference' => $interest?->reference, 'request_status' => $interest?->status];
});
$data = ['submissions' => $submissions,
'favourites' => Opportunity::published()->whereIn('id', DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->select('opportunity_id'))->get(['id', 'title', 'slug'])];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/workspace', $data);
}
public function create(Request $request, InvestmentCatalogue $catalogue): RedirectResponse|JsonResponse
{
$data = $request->validate(['opportunity_id' => ['nullable', 'integer'], 'dossier_id' => ['nullable', 'uuid'], 'company_id' => ['nullable', 'uuid'],
'zone_id' => ['nullable', 'uuid', 'prohibits:dossier_id,company_id'], 'intent' => ['nullable', 'required_with:zone_id', 'in:interest,dossier,meeting']]);
abort_if(! empty($data['opportunity_id']) && ! empty($data['dossier_id']), 422);
$payload = [...app(InvestmentWorkspace::class)->account($request->user()->id)['payload'], 'full_name' => $request->user()->name, 'email' => $request->user()->email];
if (! empty($data['company_id'])) {
$company = DB::table('investment_company_declarations')->where('id', $data['company_id'])->where('user_id', $request->user()->id)->first();
abort_unless($company !== null, 404);
$companyData = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR);
$payload = [...$payload, 'organisation' => $companyData['name'], 'representative_role' => $companyData['role'], 'incorporation_country_code' => $companyData['incorporation_country_code'] ?? null];
if (isset($companyData['organization_country_snapshot'])) {
$payload['organization_country_snapshot'] = $companyData['organization_country_snapshot'];
}
}
unset($data['company_id']);
if (! empty($data['opportunity_id'])) {
$opportunity = Opportunity::published()->with('sector')->where('id', $data['opportunity_id'])->firstOrFail();
$payload['sector'] = $opportunity->sector?->name;
$payload['location'] = implode(', ', array_filter([$opportunity->district, $opportunity->province]));
$context = $catalogue->context($opportunity->id);
foreach (['clusters' => 'cluster_ids', 'corridors' => 'corridor_ids', 'projects' => 'project_ids'] as $relation => $field) {
$payload[$field] = $context[$relation]->pluck('id')->all();
}
}
if (! empty($data['dossier_id'])) {
$dossier = InvestmentPortfolioController::visible()->where('id', $data['dossier_id'])->firstOrFail();
foreach (['cluster_id' => 'cluster_ids', 'corridor_id' => 'corridor_ids', 'project_id' => 'project_ids'] as $foreign => $field) {
if ($dossier->$foreign) {
$payload[$field] = [$dossier->$foreign];
}
}
}
$zoneId = $data['zone_id'] ?? null;
$intent = $data['intent'] ?? 'interest';
unset($data['zone_id'], $data['intent']);
$submission = DB::transaction(function () use ($request, $data, $payload, $zoneId, $intent) {
$zone = null;
if ($zoneId) {
$zones = app(InvestmentZones::class);
$zone = $zones->visible()->whereKey($zoneId)->lockForUpdate()->firstOrFail();
if (! empty($data['opportunity_id'])) {
abort_unless($zones->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id')
->where('zl.zone_id', $zoneId)->where('m.opportunity_id', $data['opportunity_id'])->exists(), 422, 'O investimento não tem associação pública à zona seleccionada.');
}
// Serializes draft creation for this investor without a global lock.
DB::table('users')->where('id', $request->user()->id)->lockForUpdate()->first();
$draft = InvestmentSubmission::where('user_id', $request->user()->id)->where('status', 'draft')
->where('opportunity_id', $data['opportunity_id'] ?? null)
->whereIn('id', DB::table('investment_submission_zones')->where('zone_id', $zoneId)->where('zone_version', $zone->version_no)->where('intent', $intent)->select('submission_id'))->first();
if ($draft) {
return $draft;
}
}
$row = InvestmentSubmission::create([...$data, 'user_id' => $request->user()->id,
'payload' => $payload, 'status' => 'draft', 'version' => 1]);
if ($zone) {
DB::table('investment_submission_zones')->insert(['submission_id' => $row->id, 'zone_id' => $zone->id, 'zone_version' => $zone->version_no, 'intent' => $intent]);
}
$this->event($row, $request, 'draft_created');
return $row;
});
return $request->expectsJson() ? response()->json(['id' => $submission->id, 'version' => $submission->version], 201)
: redirect('/investidor/manifestacoes/'.$submission->id);
}
public function show(Request $request, string $id, InvestmentCatalogue $catalogue): Response|JsonResponse
{
$row = $this->owned($request, $id);
$interest = $row->interest_request_id ? InterestRequest::find($row->interest_request_id) : null;
$target = $row->opportunity_id ? Opportunity::published()->find($row->opportunity_id) : null;
$dossier = $row->dossier_id ? InvestmentPortfolioController::visible()->find($row->dossier_id) : null;
$zoneOrigin = DB::table('investment_submission_zones')->where('submission_id', $id)->first(['zone_id', 'zone_version', 'intent']);
$contextAvailable = app(VirtualDesk::class)->installed();
$boundCase = $contextAvailable ? DB::table('desk_cases')->where('submission_id', $id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first() : null;
$contextChoices = $contextAvailable && (int) $row->user_id === (int) $request->user()->id && $row->status === 'draft' && ! $boundCase
? DB::table('desk_cases')->where('user_id', $request->user()->id)->whereNotNull('journey_id')->whereNull('closed_at')->whereNull('submission_id')->orderBy('title')->get(['id', 'title', 'version']) : [];
$data = ['contextDossierId' => $boundCase?->id, 'contextChoices' => $contextChoices, 'boundContextRevision' => $contextAvailable ? app(InvestorContextConsumers::class)->submissionRevision($id) : 0, 'countries' => app(CountryCatalogue::class)->all(), 'submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin,
'target' => ($target ? $target->title : null) ?? ($dossier ? $dossier->title : null) ?? ($row->payload['opportunity_title'] ?? null), 'references' => [
...$catalogue->references(), 'options' => DB::table('investment_reference_options')->orderBy('label')->get()],
'needs' => InvestmentSpecificNeed::where('submission_id', $id)->get(),
'attachments' => DB::table('investment_submission_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.submission_id', $id)->whereNull('d.deleted_at')->get(['a.id', 'd.file_name']),
'events' => DB::table('investment_submission_events')->where('submission_id', $id)->orderBy('id')->get(['action', 'version', 'created_at'])
->concat($interest ? DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)->orderBy('created_at')->get(['to_status', 'created_at'])->map(fn ($event) => ['action' => $event->to_status, 'version' => $row->version, 'created_at' => $event->created_at]) : [])->sortBy('created_at')->values(),
'revisions' => DB::table('investment_submission_revisions')->where('submission_id', $id)->orderByDesc('version')->get(['version', 'created_at']),
'messages' => DB::table('investment_messages')->where('submission_id', $id)->orderBy('created_at')->get(['id', 'sender_role', 'body', 'created_at'])->map(function ($message) {
$message->body = Crypt::decryptString($message->body);
return $message;
}),
'receipt' => $interest?->only(['reference', 'status', 'next_action', 'next_action_due_at']), 'management' => (int) $row->user_id !== $request->user()->id];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/submission', $data);
}
public function bindContext(Request $request, string $id): JsonResponse
{
abort_unless(app(VirtualDesk::class)->installed(), 422, 'O vínculo ao percurso do investimento não está disponível neste destino. A manifestação e a sua origem permanecem guardadas.');
abort_if(array_diff(array_keys($request->all()), ['case_id', 'case_version', 'version', '_token']) !== [], 422, 'Parâmetros de associação inválidos.');
$data = $request->validate(['case_id' => 'required|uuid', 'case_version' => 'required|integer|min:1', 'version' => 'required|integer|min:1']);
$result = DB::transaction(function () use ($request, $id, $data) {
// Serialize all links for this submission before locking the selected case.
$row = $this->owned($request, $id, true, true);
$desk = app(VirtualDesk::class);
$case = $desk->case($request->user(), $data['case_id'], true, true);
abort_unless((int) $case->user_id === (int) $request->user()->id && $case->journey_id, 403, 'Dossier próprio autorizado necessário.');
$existing = DB::table('desk_cases')->where('submission_id', $id)->first(['id']);
if ($existing && $existing->id === $case->id) {
return ['case_id' => $case->id, 'version' => $row->version, 'case_version' => (int) $case->version, 'replayed' => true];
}
abort_if($existing || $case->submission_id, 409, 'Existe um vínculo anterior; não foi substituído nem criado outro dossier.');
abort_unless($row->status === 'draft' && ! $case->closed_at && (int) $row->version === (int) $data['version'] && (int) $case->version === (int) $data['case_version'], 409, 'Rascunho ou dossier alterado. Recarregue antes de associar.');
DB::table('desk_cases')->where('id', $case->id)->update(['submission_id' => $id, 'version' => $case->version + 1, 'updated_at' => now()]);
$row->update(['version' => $row->version + 1]);
// Binding does not save form edits or assert that the context has been reviewed.
$this->event($row, $request, 'context_bound');
$desk->event($desk->case($request->user(), $case->id), $request->user(), 'manifestation_context_bound', ['submission_id' => $id, 'previous_case_version' => (int) $case->version]);
return ['case_id' => $case->id, 'version' => $row->version, 'case_version' => (int) $case->version + 1, 'replayed' => false];
}, 3);
return response()->json($result);
}
private function owned(Request $request, string $id, bool $lock = false, bool $ownerOnly = false): InvestmentSubmission
{
$query = InvestmentSubmission::whereKey($id);
if ($ownerOnly || ! $request->user()->can('manage-portal')) {
$query->where('user_id', $request->user()->id);
}
return ($lock ? $query->lockForUpdate() : $query)->firstOrFail();
}
/** @return array<string,mixed> */
private function validated(Request $request, bool $submitting): array
{
$required = $submitting ? 'required' : 'nullable';
$data = $request->validate(['version' => ['required', 'integer', 'min:1'],
'payload' => ['required', 'array:context_opportunity,context_opportunity_snapshot,context_reuse,full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload.organization_country_snapshot' => ['sometimes', 'array', 'max:10'],
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.context_opportunity' => ['nullable', 'string', 'max:100'],
'payload.context_opportunity_snapshot' => ['nullable', 'string', 'max:200000'],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload.other_details' => ['nullable', 'string', 'max:5000'],
'payload.full_name' => [$required, 'string', ...($submitting ? ['min:3'] : []), 'max:150'], 'payload.email' => [$required, $submitting ? 'email:rfc' : 'string', 'max:150'],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$submitting ? 'required_without:payload.declared_origin_country_code' : 'nullable', 'nullable', 'string', 'max:100'],
'payload.organisation' => ['nullable', 'string', 'max:200'], 'payload.representative_role' => ['nullable', 'string', 'max:150'],
'payload.experience' => ['nullable', 'string', 'max:2000'], 'payload.investment_capacity' => ['nullable', 'string', 'max:1000'],
'payload.profile_id' => [$required, 'integer'], 'payload.sector' => [$required, 'string', 'max:150'],
'payload.objective' => [$required, 'string', ...($submitting ? ['min:20'] : []), 'max:5000'], 'payload.participation_id' => [$required, 'integer'],
'payload.partnership_id' => ['nullable', 'integer'], 'payload.estimated_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'],
'payload.currency' => ['nullable', 'required_with:payload.estimated_amount', 'regex:/^[A-Z]{3}$/'],
'payload.horizon' => [$required, 'string', 'max:150'], 'payload.location' => ['nullable', 'string', 'max:500'],
'payload.cluster_ids' => ['nullable', 'array', 'max:20'], 'payload.cluster_ids.*' => ['uuid', 'distinct'],
'payload.corridor_ids' => ['nullable', 'array', 'max:20'], 'payload.corridor_ids.*' => ['uuid', 'distinct'],
'payload.project_ids' => ['nullable', 'array', 'max:30'], 'payload.project_ids.*' => ['uuid', 'distinct'],
'payload.contact_consent' => $submitting ? ['accepted'] : ['nullable', 'boolean'],
'needs' => ['nullable', 'array', 'max:40'], 'needs.*' => ['array:category_id,option_id,unit_id,priority_id,quantity,deadline,details'],
'needs.*.category_id' => ['required', 'integer'], 'needs.*.option_id' => ['required', 'integer'],
'needs.*.unit_id' => ['nullable', 'integer'], 'needs.*.priority_id' => ['nullable', 'integer'],
'needs.*.quantity' => ['nullable', 'numeric', 'min:0', 'max:999999999999'], 'needs.*.deadline' => ['nullable', 'date_format:Y-m-d'],
'needs.*.details' => ['required', 'array:description,location,observations,qualification,specialty,experience,certifications,availability,institution,training_mode,duration,status'],
'needs.*.details.*' => ['nullable', 'string', 'max:2000'], 'needs.*.details.description' => ['required', 'string', 'min:3', 'max:2000']]);
foreach (['profile_id' => 'investor_profile', 'participation_id' => 'participation', 'partnership_id' => 'partnership'] as $field => $group) {
if (! empty($data['payload'][$field])) {
$option = $this->option($data['payload'][$field], $group, 'payload.'.$field);
if ($option->requires_description && mb_strlen(trim($data['payload']['other_details'] ?? '')) < 3) {
throw ValidationException::withMessages(['payload.other_details' => 'Especifique as opções assinaladas como Outro.']);
}
}
}
foreach (['cluster_ids' => 'cluster_id', 'corridor_ids' => 'corridor_id', 'project_ids' => 'project_id'] as $field => $column) {
$ids = $data['payload'][$field] ?? [];
if ($ids && InvestmentPortfolioController::visible()->whereIn($column, $ids)->count() !== count($ids)) {
throw ValidationException::withMessages(['payload.'.$field => 'Uma referência territorial já não está publicada.']);
}
}
foreach ($data['needs'] ?? [] as $index => $need) {
$category = $this->option($need['category_id'], 'need_category', 'needs.'.$index.'.category_id');
$group = match ($category->code) {
'labour_required', 'labour_available' => 'labour',
'infrastructure_existing', 'infrastructure_required' => 'infrastructure',
'education' => 'education', default => 'support',
};
$this->option($need['option_id'], $group, 'needs.'.$index.'.option_id');
foreach (['unit_id' => 'unit', 'priority_id' => 'priority'] as $field => $expected) {
if (! empty($need[$field])) {
$this->option($need[$field], $expected, 'needs.'.$index.'.'.$field);
}
}
}
$bound = DB::table('desk_cases')->where('submission_id', $request->route('id'))->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first();
if ($bound) {
$data['payload'] = app(InvestmentConsumerScope::class)->bind($request->user(), $bound->id, $data['payload'], $submitting);
} else {
unset($data['payload']['context_opportunity'], $data['payload']['context_opportunity_snapshot']);
}
return $data;
}
private function option(int $id, string $group, string $field): \stdClass
{
$option = DB::table('investment_reference_options')->where('id', $id)->where('group', $group)->where('active', true)->first();
if (! $option) {
throw ValidationException::withMessages([$field => 'Seleccione uma opção activa desta categoria.']);
}
return $option;
}
public function save(Request $request, string $id): RedirectResponse|JsonResponse
{
$data = $this->validated($request, false);
$version = DB::transaction(function () use ($request, $id, $data) {
$row = $this->owned($request, $id, true, true);
abort_unless($row->status === 'draft' && $row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue antes de guardar.');
$row->update([...(isset($data['payload']['context_opportunity']) ? ['opportunity_id' => (int) substr($data['payload']['context_opportunity'], 12)] : []), 'payload' => $this->preserveCountrySource($row, $data['payload']), 'version' => $row->version + 1]);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
}
$this->event($row, $request, 'draft_saved');
return $row->version;
});
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Rascunho guardado.');
}
/** @param array<string, mixed> $payload
* @return array<string, mixed> */
private function preserveCountrySource(InvestmentSubmission $row, array $payload): array
{
$source = $row->payload['organization_country_snapshot'] ?? null;
if (isset($payload['organization_country_snapshot']) && (! is_array($payload['organization_country_snapshot']) || ! is_array($source) ||
Arr::sortRecursive($payload['organization_country_snapshot']) !== Arr::sortRecursive($source))) {
throw ValidationException::withMessages(['payload.organization_country_snapshot' => 'A proveniência é recuperada no servidor; não pode ser substituída pelo formulário.']);
}
unset($payload['organization_country_snapshot']);
if ($source && ($row->payload['incorporation_country_code'] ?? null) === ($payload['incorporation_country_code'] ?? null)) {
$payload['organization_country_snapshot'] = $source;
}
return $payload;
}
public function submit(Request $request, string $id, InterestSubmission $intake): RedirectResponse|JsonResponse
{
// Replaying a completed submission returns only the owner's existing receipt.
$existing = $this->owned($request, $id, false, true);
if ($existing->status === 'submitted') {
$reference = InterestRequest::findOrFail($existing->interest_request_id)->reference;
} else {
$data = $this->validated($request, true);
$reference = DB::transaction(function () use ($request, $id, $data, $intake) {
$row = $this->owned($request, $id, true, true);
if ($row->status === 'submitted') {
return InterestRequest::findOrFail($row->interest_request_id)->reference;
}
abort_unless($row->version === (int) $data['version'], 409, 'Rascunho alterado. Recarregue.');
$scopeKey = $data['payload']['context_opportunity'] ?? null;
$opportunity = $scopeKey ? Opportunity::published()->findOrFail(substr($scopeKey, 12)) : ($row->opportunity_id ? Opportunity::published()->findOrFail($row->opportunity_id) : null);
if ($row->dossier_id) {
InvestmentPortfolioController::visible()->findOrFail($row->dossier_id);
}
$p = $this->preserveCountrySource($row, $data['payload']);
$zoneOrigin = DB::table('investment_submission_zones')->where('submission_id', $id)->first(['zone_id', 'zone_version', 'intent']);
if ($zoneOrigin) {
$zone = app(InvestmentZones::class)->visible()->whereKey($zoneOrigin->zone_id)->lockForUpdate()->firstOrFail();
abort_unless((int) $zone->version_no === (int) $zoneOrigin->zone_version, 409, 'A ficha da zona mudou. Consulte a versão actual antes de preparar novo pedido.');
if ($opportunity) {
abort_unless(app(InvestmentZones::class)->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id')
->where('zl.zone_id', $zoneOrigin->zone_id)->where('m.opportunity_id', $opportunity->id)->exists(), 422, 'A associação à zona deixou de estar disponível.');
}
}
$interest = $intake->receive(['email' => $p['email'], 'fullName' => $p['full_name'], 'message' => $p['objective'], 'submission_id' => $id], $id,
fn () => InterestRequest::create(['reference' => 'IR-'.now()->year.'-'.Str::ulid(), 'opportunity_id' => $opportunity?->id,
'opportunity_reference' => $opportunity?->reference, 'full_name' => $p['full_name'], 'email' => $p['email'], 'phone' => $p['phone'] ?? null,
'country' => $p['country'] ?? 'Não informado', 'investor_type' => empty($p['organisation']) ? 'individual' : 'empresa',
'sector_interest' => $p['sector'], 'message' => $p['objective'], 'privacy_consent' => true, 'consented_at' => now(),
'status' => 'received', 'source_page' => '/investidor/manifestacoes/'.$id,
'metadata' => ['submission_id' => $id, 'portal_user_id' => $request->user()->id, 'dossier_id' => $row->dossier_id,
'context_opportunity' => $p['context_opportunity'] ?? null,
'context_opportunity_snapshot' => $p['context_opportunity_snapshot'] ?? null,
'declared_origin_country_code' => $p['declared_origin_country_code'] ?? null,
'residence_country_code' => $p['residence_country_code'] ?? null,
'incorporation_country_code' => $p['incorporation_country_code'] ?? null,
...(isset($p['organization_country_snapshot']) ? ['organization_country_snapshot' => $p['organization_country_snapshot']] : []),
...($zoneOrigin ? ['zone_origin' => (array) $zoneOrigin] : [])]]));
$row->update(['opportunity_id' => $opportunity?->id, 'payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']);
InvestmentSpecificNeed::where('submission_id', $id)->delete();
foreach ($data['needs'] ?? [] as $need) {
InvestmentSpecificNeed::create([...$need, 'submission_id' => $id]);
}
$this->event($row, $request, 'submitted');
app(InvestmentWorkspace::class)->notify($row->user_id, $row, 'received', 'received:'.$row->id, ['reference' => $interest->reference]);
return $interest->reference;
});
}
$status = InterestRequest::where('reference', $reference)->value('status');
return $request->expectsJson() ? response()->json(['reference' => $reference, 'status' => $status]) : back()->with('success', 'Manifestação recebida: '.$reference);
}
public function attach(Request $request, string $id): RedirectResponse|JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer'], 'file' => ['required', 'file', 'mimetypes:application/pdf,text/plain', 'max:10240']]);
$file = $request->file('file');
$documentId = (string) Str::uuid();
$key = null;
try {
$version = DB::transaction(function () use ($request, $id, $data, $file, $documentId, &$key) {
$row = $this->owned($request, $id, true, true);
abort_unless($row->status === 'draft' && $row->version === (int) $data['version'], 409);
abort_if(DB::table('investment_submission_attachments')->where('submission_id', $id)->count() >= 10, 422, 'Máximo de dez anexos por pedido.');
$key = $file->storeAs('investment-submissions/'.$id, $documentId, 'local');
abort_unless($key !== false, 500);
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'investor-submission', 'file_name' => basename($file->getClientOriginalName()),
'media_type' => $file->getMimeType(), 'storage_key' => $key, 'sha256' => hash_file('sha256', Storage::disk('local')->path($key)),
'size_bytes' => $file->getSize(), 'uploaded_by' => $request->user()->id,
'status' => 'quarantined', 'data_classification' => 'confidential']);
DB::table('investment_submission_attachments')->insert(['id' => (string) Str::uuid(), 'submission_id' => $id, 'document_id' => $documentId, 'created_at' => now(), 'updated_at' => now()]);
$row->increment('version');
$this->event($row, $request, 'attachment_added');
return $row->version;
});
} catch (\Throwable $e) {
if (is_string($key)) {
Storage::disk('local')->delete($key);
}
throw $e;
}
ScanDocumentForMalware::dispatch($documentId)->afterCommit();
return $request->expectsJson() ? response()->json(['version' => $version]) : back()->with('success', 'Anexo privado guardado.');
}
public function download(Request $request, string $id, string $attachment): BinaryFileResponse
{
$this->owned($request, $id);
$doc = DB::table('investment_submission_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.id', $attachment)->where('a.submission_id', $id)->where('d.status', 'active')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256')
->first(['d.file_name', 'd.storage_key', 'd.sha256', 'd.malware_scanned_sha256']);
abort_unless($doc !== null, 404);
$root = realpath(Storage::disk('local')->path('investment-submissions/'.$id));
$path = realpath(Storage::disk('local')->path($doc->storage_key));
abort_unless($root && $path && str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)) && is_file($path), 404);
$hash = hash_file('sha256', $path);
abort_unless(is_string($hash) && hash_equals($doc->sha256, $hash)
&& hash_equals($doc->sha256, (string) $doc->malware_scanned_sha256), 404);
return response()->download($path, basename($doc->file_name), ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
}
private function event(InvestmentSubmission $row, Request $request, string $action): void
{
$boundCase = app(VirtualDesk::class)->installed() ? DB::table('desk_cases')->where('submission_id', $row->id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first() : null;
$binding = null;
if ($boundCase) {
$binding = app(InvestorContextConsumers::class)->submissionRevision($row->id);
if ($action === 'submitted' || $request->boolean('context_reviewed')) {
$binding = app(InvestorContextConsumers::class)->assertReviewed($request->user(), $boundCase->id, $request->all(), $binding);
}
}
app(InvestmentWorkspace::class)->snapshot($row);
DB::table('investment_submission_events')->insert(['submission_id' => $row->id, 'actor_id' => $request->user()->id, 'action' => $action, 'version' => $row->version, 'created_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'investment-submission', 'entity_type' => 'investment_submissions',
'entity_id' => $row->id, 'actor_user_id' => $request->user()->id, 'action' => $action,
'after_data' => json_encode(['version' => $row->version, 'status' => $row->status, ...($binding !== null ? ['investment_context_revision' => $binding] : [])], JSON_THROW_ON_ERROR)]);
}
}