Receber documentos privados e locais de disponibilidade nas candidaturas
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -7,6 +7,7 @@ use App\Models\PeopleProcess;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\CandidateAccess;
|
||||
use App\Services\CandidateDocuments;
|
||||
use App\Services\CandidateProfiles;
|
||||
use App\Services\CandidateSubmissions;
|
||||
use App\Services\CandidateTriage;
|
||||
@@ -200,7 +201,43 @@ class CandidatePortalController extends Controller
|
||||
abort_unless($row !== null, 404);
|
||||
app(CandidateProfiles::class)->event($account, 'submission.consulted', ['submission_id' => $row->id]);
|
||||
|
||||
return $this->page($request, 'candidate/receipt', ['companyId' => $company, 'decision' => app(CandidateTriage::class)->publicDecision($company, $submission), 'receipt' => ['reference' => $row->reference, 'received_at' => $row->created_at, 'snapshot' => json_decode($row->snapshot, true, 32, JSON_THROW_ON_ERROR)]]);
|
||||
return $this->page($request, 'candidate/receipt', ['companyId' => $company, 'decision' => app(CandidateTriage::class)->publicDecision($company, $submission), 'receipt' => ['id' => $row->id, 'reference' => $row->reference, 'received_at' => $row->created_at, 'snapshot' => json_decode($row->snapshot, true, 32, JSON_THROW_ON_ERROR)]]);
|
||||
}
|
||||
|
||||
public function candidateDocument(Request $request, int $company, int $submission, string $document): Response
|
||||
{
|
||||
$account = $this->account($request, $company);
|
||||
abort_unless($account !== null, 401);
|
||||
$row = DB::table('candidate_submissions')->where('tenant_id', $company)->where('candidate_account_id', $account->id)->where('id', $submission)->first();
|
||||
abort_unless($row !== null, 404);
|
||||
$file = $this->submissionDocument($company, $row->people_process_id, $submission, $document);
|
||||
app(CandidateProfiles::class)->event($account, 'submission.document_downloaded', ['submission_id' => $submission, 'document_key' => $document]);
|
||||
|
||||
return app(CandidateDocuments::class)->download((array) $file);
|
||||
}
|
||||
|
||||
public function staffDocument(Request $request, int $process, int $submission, string $document): Response
|
||||
{
|
||||
return DB::transaction(function () use ($request, $process, $submission, $document) {
|
||||
$actor = $this->staff($request);
|
||||
app(ConflictGuard::class)->lockCompany($actor);
|
||||
$actor = app(CurrentActor::class)->lock($actor);
|
||||
$record = PeopleProcess::query()->where('tenant_id', $actor->tenant_id)->where('id', $process)->firstOrFail();
|
||||
abort_unless(app(CandidateTriage::class)->canRead($actor, $record), 403);
|
||||
$file = $this->submissionDocument($actor->tenant_id, $process, $submission, $document);
|
||||
app(RequisitionWorkflow::class)->audit($actor, 'candidate_submission.document_downloaded', null, null, ['process_id' => $process, 'submission_id' => $submission, 'document_key' => $document]);
|
||||
|
||||
return app(CandidateDocuments::class)->download((array) $file);
|
||||
});
|
||||
}
|
||||
|
||||
private function submissionDocument(int $company, int $process, int $submission, string $key): \stdClass
|
||||
{
|
||||
abort_unless(Schema::hasTable('candidate_submission_documents'), 404);
|
||||
$row = DB::table('candidate_submission_documents')->where('tenant_id', $company)->where('people_process_id', $process)->where('candidate_submission_id', $submission)->where('document_key', $key)->first();
|
||||
abort_unless($row !== null, 404);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function inbox(Request $request, int $process, CandidateSubmissions $submissions): Response
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use ZipArchive;
|
||||
|
||||
class CandidateDocuments
|
||||
{
|
||||
/** @param array<string,mixed> $files
|
||||
* @return array<string,array<string,mixed>>
|
||||
*/
|
||||
public function fingerprint(array $files): array
|
||||
{
|
||||
$result = [];
|
||||
$total = 0;
|
||||
foreach ($files as $key => $file) {
|
||||
Validator::make(['file' => $file], ['file' => 'required|file|min:0.001|max:51200'])->validate();
|
||||
abort_unless($file instanceof UploadedFile, 422);
|
||||
$total += $file->getSize();
|
||||
$name = Str::substr(Str::replaceMatches('/[^\pL\pN ._()-]/u', '_', basename(str_replace('\\', '/', $file->getClientOriginalName()))), 0, 180);
|
||||
$result[$key] = ['name' => $name, 'size' => $file->getSize(), 'sha256' => hash_file('sha256', $file->getPathname()), 'mime' => $file->getMimeType()];
|
||||
}
|
||||
abort_if($total > max(1, (int) config('candidate_documents.max_total_kb')) * 1024, 422, 'O conjunto de anexos excede o limite técnico de envio.');
|
||||
ksort($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $definition
|
||||
* @param array<string,mixed> $files
|
||||
*/
|
||||
public function validate(array $definition, array $files): void
|
||||
{
|
||||
$documents = $definition['documents'];
|
||||
$keys = array_column($documents, 'key');
|
||||
$rules = ['documents' => $keys === [] ? 'present|array|max:0' : 'present|array:'.implode(',', $keys)];
|
||||
foreach ($documents as $document) {
|
||||
$rules['documents.'.$document['key']] = ($document['required'] ? 'required' : 'sometimes').'|file|min:0.001|max:'.$document['max_kb'].'|mimes:'.implode(',', $document['formats']).'|extensions:'.implode(',', $document['formats']);
|
||||
}
|
||||
Validator::make(['documents' => $files], $rules)->validate();
|
||||
foreach ($files as $key => $file) {
|
||||
abort_unless($file instanceof UploadedFile, 422);
|
||||
$valid = match (strtolower($file->getClientOriginalExtension())) {
|
||||
'pdf' => str_starts_with((string) file_get_contents($file->getPathname(), false, null, 0, 5), '%PDF-'),
|
||||
'jpg', 'png' => @getimagesize($file->getPathname()) !== false,
|
||||
'docx' => $this->wordDocument($file),
|
||||
default => false,
|
||||
};
|
||||
Validator::make(['documents' => [$key => $valid]], ['documents.'.$key => 'accepted'], ['accepted' => 'O conteúdo do documento não corresponde ao formato indicado.'])->validate();
|
||||
}
|
||||
}
|
||||
|
||||
private function wordDocument(UploadedFile $file): bool
|
||||
{
|
||||
if (! class_exists(ZipArchive::class)) {
|
||||
return false;
|
||||
}
|
||||
$zip = new ZipArchive;
|
||||
if ($zip->open($file->getPathname()) !== true) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if ($zip->numFiles > 2000) {
|
||||
return false;
|
||||
}
|
||||
$total = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$entry = $zip->statIndex($i);
|
||||
if ($entry === false || str_contains($entry['name'], '..') || str_starts_with($entry['name'], '/') || str_contains($entry['name'], '\\')) {
|
||||
return false;
|
||||
}
|
||||
$total += $entry['size'];
|
||||
if ($total > 100 * 1024 * 1024 || preg_match('/(?:vbaProject\.bin|\.(?:exe|dll|js|vbs))$/i', $entry['name'])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
foreach (['[Content_Types].xml', 'word/document.xml'] as $part) {
|
||||
$stat = $zip->statName($part);
|
||||
if ($stat === false || $stat['size'] > 10 * 1024 * 1024) {
|
||||
return false;
|
||||
}
|
||||
$xml = $zip->getFromName($part);
|
||||
if ($xml === false || stripos($xml, '<!DOCTYPE') !== false || stripos($xml, '<!ENTITY') !== false) {
|
||||
return false;
|
||||
}
|
||||
$document = new \DOMDocument;
|
||||
if (! @$document->loadXML($xml, LIBXML_NONET)) {
|
||||
return false;
|
||||
}
|
||||
if ($part === 'word/document.xml' && ($document->documentElement?->localName !== 'document' || $document->documentElement->namespaceURI !== 'http://schemas.openxmlformats.org/wordprocessingml/2006/main')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $document */
|
||||
public function download(array $document): StreamedResponse
|
||||
{
|
||||
$disk = Storage::disk('candidate_documents');
|
||||
abort_unless($disk->exists($document['path']), 404);
|
||||
$stream = $disk->readStream($document['path']);
|
||||
abort_unless(is_resource($stream), 404);
|
||||
try {
|
||||
$context = hash_init('sha256');
|
||||
hash_update_stream($context, $stream);
|
||||
abort_unless(hash_equals($document['sha256'], hash_final($context)), 409, 'A integridade do documento exige conferência.');
|
||||
} finally {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
return $disk->download($document['path'], $document['original_name'], ['Content-Type' => 'application/octet-stream', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'private, no-store', 'Content-Security-Policy' => "default-src 'none'; sandbox", 'X-Robots-Tag' => 'noindex, nofollow']);
|
||||
}
|
||||
}
|
||||
@@ -79,13 +79,27 @@ class CandidateProfiles
|
||||
$data = Validator::make($input, ['version' => 'required|integer|min:0', 'name' => 'required|string|min:3|max:200', 'phone' => 'nullable|string|max:50',
|
||||
'summary' => 'required|string|min:10|max:5000', 'availability' => 'required|array:province,district,locality',
|
||||
'availability.province' => 'required|string|max:150', 'availability.district' => 'required|string|max:150', 'availability.locality' => 'required|string|max:150',
|
||||
'additional_availability' => 'sometimes|array|list|max:20',
|
||||
'additional_availability.*' => 'required|array:province,district,locality',
|
||||
'additional_availability.*.province' => 'required|string|max:150',
|
||||
'additional_availability.*.district' => 'required|string|max:150',
|
||||
'additional_availability.*.locality' => 'required|string|max:150',
|
||||
'reason' => 'required|string|min:10|max:4000'])->validate();
|
||||
$previous = $this->profile($account);
|
||||
$data['additional_availability'] ??= $previous['data']['additional_availability'] ?? [];
|
||||
$seen = [];
|
||||
foreach ([$data['availability'], ...$data['additional_availability']] as $location) {
|
||||
$key = json_encode(array_map(fn ($part) => mb_strtolower(trim($location[$part])), ['province', 'district', 'locality']), JSON_THROW_ON_ERROR);
|
||||
if (isset($seen[$key])) {
|
||||
throw ValidationException::withMessages(['additional_availability' => 'Indique cada local de disponibilidade apenas uma vez.']);
|
||||
}
|
||||
$seen[$key] = true;
|
||||
}
|
||||
if ((int) $data['version'] !== $previous['version']) {
|
||||
throw ValidationException::withMessages(['version' => 'O perfil mudou. Actualize e confira antes de guardar.']);
|
||||
}
|
||||
$id = DB::table('candidate_profile_versions')->insertGetId(['tenant_id' => $account->tenant_id, 'candidate_account_id' => $account->id,
|
||||
'version' => $previous['version'] + 1, 'data' => json_encode(array_intersect_key($data, array_flip(['name', 'phone', 'summary', 'availability'])), JSON_THROW_ON_ERROR),
|
||||
'version' => $previous['version'] + 1, 'data' => json_encode(array_intersect_key($data, array_flip(['name', 'phone', 'summary', 'availability', 'additional_availability'])), JSON_THROW_ON_ERROR),
|
||||
'reason' => $data['reason'], 'created_at' => now()]);
|
||||
$this->event($account, 'profile.version_created', ['profile_version_id' => $id, 'version' => $previous['version'] + 1]);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Models\CandidateAccount;
|
||||
use App\Models\PeopleProcess;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -32,7 +34,7 @@ class CandidateSubmissions
|
||||
abort_unless($form !== null && $form['definition']['available'] && app(CandidateProfiles::class)->portal($actor->tenant_id) !== null, 422, 'Configure o portal e prepare um processo com formulário disponível.');
|
||||
foreach ($form['definition']['documents'] as $document) {
|
||||
if ($document['required']) {
|
||||
throw ValidationException::withMessages(['active' => 'Este formulário exige anexos. A recepção electrónica de anexos ainda está em desenvolvimento; não publique sem esse percurso.']);
|
||||
abort_unless(Schema::hasTable('candidate_submission_documents'), 503, 'Instalação da recepção de documentos pendente.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,36 +64,63 @@ class CandidateSubmissions
|
||||
/** @param array<string,mixed> $input */
|
||||
public function submit(CandidateAccount $actor, int $processId, array $input): int
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $processId, $input) {
|
||||
DB::table('tenants')->where('id', $actor->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
$sessionVersion = $actor->session_version;
|
||||
$actor = CandidateAccount::query()->where('id', $actor->id)->lockForUpdate()->firstOrFail();
|
||||
abort_unless($actor->session_version === $sessionVersion, 401);
|
||||
abort_unless($actor->email_verified_at !== null, 403, 'Confirme o email antes de submeter.');
|
||||
$data = Validator::make($input, ['operation_key' => 'required|uuid', 'profile_version' => 'required|integer|min:1', 'publication_version' => 'required|integer|min:1', 'answers' => 'present|array', 'documents' => 'sometimes|array|max:0'])->validate();
|
||||
$hash = hash('sha256', json_encode([$processId, $data], JSON_THROW_ON_ERROR));
|
||||
$previous = DB::table('candidate_submissions')->where('candidate_account_id', $actor->id)->where('operation_key', $data['operation_key'])->first();
|
||||
if ($previous !== null) {
|
||||
abort_unless($previous->people_process_id === $processId && $previous->input_hash === $hash, 409, 'Esta operação já foi utilizada com outros dados.');
|
||||
$stored = [];
|
||||
try {
|
||||
return DB::transaction(function () use ($actor, $processId, $input, &$stored) {
|
||||
DB::table('tenants')->where('id', $actor->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
$sessionVersion = $actor->session_version;
|
||||
$actor = CandidateAccount::query()->where('id', $actor->id)->lockForUpdate()->firstOrFail();
|
||||
abort_unless($actor->session_version === $sessionVersion, 401);
|
||||
abort_unless($actor->email_verified_at !== null, 403, 'Confirme o email antes de submeter.');
|
||||
$data = Validator::make($input, ['operation_key' => 'required|uuid', 'profile_version' => 'required|integer|min:1', 'publication_version' => 'required|integer|min:1', 'answers' => 'present|array', 'documents' => 'sometimes|array|max:20'])->validate();
|
||||
$documents = app(CandidateDocuments::class);
|
||||
$fingerprint = $documents->fingerprint($data['documents'] ?? []);
|
||||
$hashData = $data;
|
||||
if (array_key_exists('documents', $hashData)) {
|
||||
$hashData['documents'] = $fingerprint;
|
||||
}
|
||||
$hash = hash('sha256', json_encode([$processId, $hashData], JSON_THROW_ON_ERROR));
|
||||
$previous = DB::table('candidate_submissions')->where('candidate_account_id', $actor->id)->where('operation_key', $data['operation_key'])->first();
|
||||
if ($previous !== null) {
|
||||
abort_unless($previous->people_process_id === $processId && $previous->input_hash === $hash, 409, 'Esta operação já foi utilizada com outros dados.');
|
||||
|
||||
return $previous->id;
|
||||
}
|
||||
$opportunity = $this->opportunity($actor->tenant_id, $processId);
|
||||
$profile = app(CandidateProfiles::class)->profile($actor);
|
||||
if ((int) $data['profile_version'] !== $profile['version'] || (int) $data['publication_version'] !== $opportunity['publication_version']) {
|
||||
throw ValidationException::withMessages(['version' => 'O perfil ou a publicação mudou. Actualize e confira antes de submeter.']);
|
||||
}
|
||||
abort_if(DB::table('candidate_submissions')->where('people_process_id', $processId)->where('candidate_account_id', $actor->id)->exists(), 409, 'Já apresentou candidatura a esta oportunidade.');
|
||||
foreach ($opportunity['form']['definition']['documents'] as $document) {
|
||||
abort_if($document['required'], 422, 'Faltam anexos obrigatórios.');
|
||||
}
|
||||
$answers = app(SubmissionAnswers::class)->validate($opportunity['form']['definition'], $data['answers']);
|
||||
$id = DB::table('candidate_submissions')->insertGetId(['tenant_id' => $actor->tenant_id, 'candidate_account_id' => $actor->id, 'people_process_id' => $processId,
|
||||
'operation_key' => $data['operation_key'], 'reference' => 'CAND-'.strtoupper((string) Str::uuid()), 'input_hash' => $hash,
|
||||
'snapshot' => json_encode(['opportunity' => $opportunity, 'profile' => $profile, 'email' => $actor->email, 'answers' => $answers, 'documents' => [], 'status' => 'received'], JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
app(CandidateProfiles::class)->event($actor, 'submission.received', ['submission_id' => $id, 'process_id' => $processId, 'profile_version' => $profile['version']]);
|
||||
return $previous->id;
|
||||
}
|
||||
$opportunity = $this->opportunity($actor->tenant_id, $processId);
|
||||
$profile = app(CandidateProfiles::class)->profile($actor);
|
||||
if ((int) $data['profile_version'] !== $profile['version'] || (int) $data['publication_version'] !== $opportunity['publication_version']) {
|
||||
throw ValidationException::withMessages(['version' => 'O perfil ou a publicação mudou. Actualize e confira antes de submeter.']);
|
||||
}
|
||||
abort_if(DB::table('candidate_submissions')->where('people_process_id', $processId)->where('candidate_account_id', $actor->id)->exists(), 409, 'Já apresentou candidatura a esta oportunidade.');
|
||||
$documents->validate($opportunity['form']['definition'], $data['documents'] ?? []);
|
||||
$answers = app(SubmissionAnswers::class)->validate($opportunity['form']['definition'], $data['answers']);
|
||||
$metadata = [];
|
||||
if ($fingerprint !== []) {
|
||||
abort_unless(Schema::hasTable('candidate_submission_documents'), 503, 'Instalação da recepção de documentos pendente.');
|
||||
foreach ($fingerprint as $key => $details) {
|
||||
$path = $data['documents'][$key]->store($actor->tenant_id.'/'.$actor->id, 'candidate_documents');
|
||||
abort_unless(is_string($path), 500);
|
||||
$stored[$key] = $path;
|
||||
$metadata[$key] = [...$details, 'key' => $key, 'verification' => 'format_checked'];
|
||||
}
|
||||
}
|
||||
$id = DB::table('candidate_submissions')->insertGetId(['tenant_id' => $actor->tenant_id, 'candidate_account_id' => $actor->id, 'people_process_id' => $processId,
|
||||
'operation_key' => $data['operation_key'], 'reference' => 'CAND-'.strtoupper((string) Str::uuid()), 'input_hash' => $hash,
|
||||
'snapshot' => json_encode(['opportunity' => $opportunity, 'profile' => $profile, 'email' => $actor->email, 'answers' => $answers, 'documents' => $metadata, 'status' => 'received'], JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
||||
foreach ($stored as $key => $path) {
|
||||
DB::table('candidate_submission_documents')->insert(['tenant_id' => $actor->tenant_id, 'people_process_id' => $processId, 'candidate_submission_id' => $id,
|
||||
'document_key' => $key, 'path' => $path, 'original_name' => $fingerprint[$key]['name'], 'mime_type' => $fingerprint[$key]['mime'],
|
||||
'size' => $fingerprint[$key]['size'], 'sha256' => $fingerprint[$key]['sha256'], 'created_at' => now()]);
|
||||
}
|
||||
app(CandidateProfiles::class)->event($actor, 'submission.received', ['submission_id' => $id, 'process_id' => $processId, 'profile_version' => $profile['version']]);
|
||||
|
||||
return $id;
|
||||
});
|
||||
return $id;
|
||||
});
|
||||
} catch (\Throwable $error) {
|
||||
foreach ($stored as $path) {
|
||||
Storage::disk('candidate_documents')->delete($path);
|
||||
}
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user