*/ public function receipts(CandidateAccount $account, int $page = 1): LengthAwarePaginator { $path = '/candidaturas/'.$account->tenant_id.'/perfil'; if (! Schema::hasTable('candidate_submissions')) { return (new LengthAwarePaginator([], 0, 12, $page))->withPath($path)->setPageName('submissions_page'); } return DB::table('candidate_submissions')->where('tenant_id', $account->tenant_id)->where('candidate_account_id', $account->id) ->orderByDesc('id')->paginate(12, ['id', 'reference', 'created_at', 'snapshot->opportunity->title as title'], 'submissions_page', $page)->withPath($path); } /** Only explicitly published, current and open opportunities; no internal process fields. * @return LengthAwarePaginator */ public function publicOpportunities(int $company, int $page = 1): LengthAwarePaginator { $path = '/candidaturas/'.$company; if (! Schema::hasTable('candidate_opportunity_publications')) { return (new LengthAwarePaginator([], 0, 12, $page))->withPath($path)->setPageName('opportunities_page'); } return DB::table('candidate_opportunity_publications as p')->join('people_processes as r', 'r.id', '=', 'p.people_process_id') ->where('p.tenant_id', $company)->where('r.tenant_id', $company)->where('p.active', true) ->where('r.status', 'open')->where('r.closes_on', '>=', today()->toDateString()) ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('candidate_opportunity_publications as newer') ->whereColumn('newer.people_process_id', 'p.people_process_id')->whereColumn('newer.tenant_id', 'p.tenant_id')->whereColumn('newer.version', '>', 'p.version')) ->orderBy('r.closes_on')->orderBy('r.id') ->paginate(12, ['r.id', 'r.title', 'r.kind', 'r.closes_on', 'p.description'], 'opportunities_page', $page)->withPath($path); } /** @param array $input */ public function publish(User $actor, int $processId, array $input): void { DB::transaction(function () use ($actor, $processId, $input) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor); $record = PeopleProcess::query()->where('tenant_id', $actor->tenant_id)->where('id', $processId)->lockForUpdate()->firstOrFail(); abort_unless(app(PeopleHiring::class)->allowed($actor, $record->kind, 'prepare') && app(PeopleAccess::class)->can($actor, $record, 'prepare') && ! app(OrganizationAccess::class)->maintenanceOnly($actor), 403); $data = Validator::make($input, ['version' => 'required|integer|min:0', 'active' => 'required|boolean', 'description' => 'required|string|min:10|max:5000', 'reason' => 'required|string|min:10|max:4000'])->validate(); $last = $this->publication($record); if ((int) $data['version'] !== (int) ($last->version ?? 0)) { throw ValidationException::withMessages(['version' => 'A publicação mudou. Actualize antes de guardar.']); } if ($data['active']) { abort_unless($record->status === 'open' && $record->closes_on >= today()->toDateString(), 422, 'O processo não está aberto.'); $form = $record->rules['submission_form'] ?? null; 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']) { abort_unless(Schema::hasTable('candidate_submission_documents'), 503, 'Instalação da recepção de documentos pendente.'); } } } $id = DB::table('candidate_opportunity_publications')->insertGetId(['tenant_id' => $actor->tenant_id, 'people_process_id' => $record->id, 'created_by' => $actor->id, 'version' => (int) $data['version'] + 1, 'active' => $data['active'], 'description' => $data['description'], 'reason' => $data['reason'], 'created_at' => now()]); app(RequisitionWorkflow::class)->audit($actor, 'candidate_opportunity.published', null, $last === null ? null : (array) $last, ['id' => $id, 'process_id' => $record->id, ...$data]); }); } public function publication(PeopleProcess $record): ?stdClass { return DB::table('candidate_opportunity_publications')->where('tenant_id', $record->tenant_id)->where('people_process_id', $record->id)->latest('version')->first(); } /** @return array */ public function opportunity(int $company, int $processId): array { $record = PeopleProcess::withoutGlobalScope('tenant')->where('tenant_id', $company)->where('id', $processId)->firstOrFail(); $publication = $this->publication($record); $form = $record->rules['submission_form'] ?? null; abort_unless($publication !== null && $publication->active && $record->status === 'open' && $record->closes_on >= today()->toDateString() && $form !== null && $form['definition']['available'], 404); return ['id' => $record->id, 'title' => $record->title, 'kind' => $record->kind, 'closes_on' => $record->closes_on, 'description' => $publication->description, 'publication_version' => $publication->version, 'form' => $form]; } /** @param array $input */ public function submit(CandidateAccount $actor, int $processId, array $input): int { $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.'); $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; }); } catch (\Throwable $error) { foreach ($stored as $path) { Storage::disk('candidate_documents')->delete($path); } throw $error; } } }