diff --git a/app/Http/Controllers/CandidatePortalController.php b/app/Http/Controllers/CandidatePortalController.php index 309e684..d7deae9 100644 --- a/app/Http/Controllers/CandidatePortalController.php +++ b/app/Http/Controllers/CandidatePortalController.php @@ -59,13 +59,20 @@ class CandidatePortalController extends Controller public function register(Request $request, int $company, CandidateProfiles $profiles): Response { + $selection = $request->validate(['opportunity_id' => 'nullable|integer|min:1|max:2147483647']); $account = $profiles->register($company, $request->all()); Auth::guard('candidate')->login($account, false); $request->session()->put('candidate.session_version', $account->session_version); $request->session()->regenerate(); Inertia::clearHistory(); - return redirect('/candidaturas/'.$company.'/perfil'); + $selected = $this->rememberOpportunity($request, $account, isset($selection['opportunity_id']) ? (int) $selection['opportunity_id'] : null); + $response = redirect('/candidaturas/'.$company.'/perfil'); + if (isset($selection['opportunity_id']) && $selected === null) { + $response->with('candidate_access_status', 'opportunity_unavailable'); + } + + return $response; } public function login(Request $request, int $company, CandidateProfiles $profiles): Response @@ -85,16 +92,12 @@ class CandidatePortalController extends Controller $request->session()->regenerate(); Inertia::clearHistory(); + $selected = $this->rememberOpportunity($request, $account, isset($data['opportunity_id']) ? (int) $data['opportunity_id'] : null); + if ($selected !== null) { + return redirect('/candidaturas/'.$company.'/oportunidades/'.$selected['id']); + } if (isset($data['opportunity_id'])) { - try { - // Recheck current publication, company and deadline after authentication. - app(CandidateSubmissions::class)->opportunity($company, (int) $data['opportunity_id']); - - return redirect('/candidaturas/'.$company.'/oportunidades/'.$data['opportunity_id']); - } catch (NotFoundHttpException|ModelNotFoundException) { - // A withdrawn, expired or foreign opportunity cannot become an authenticated destination. - return redirect('/candidaturas/'.$company.'/perfil')->with('candidate_access_status', 'opportunity_unavailable'); - } + return redirect('/candidaturas/'.$company.'/perfil')->with('candidate_access_status', 'opportunity_unavailable'); } return redirect('/candidaturas/'.$company.'/perfil'); @@ -109,10 +112,13 @@ class CandidatePortalController extends Controller $profiles->event($account, 'profile.consulted', []); $filter = $request->validate(['opportunities_page' => 'sometimes|integer|min:1']); $opportunities = app(CandidateSubmissions::class)->publicOpportunities($company, (int) ($filter['opportunities_page'] ?? 1))->withPath('/candidaturas/'.$company.'/perfil'); + $selection = $request->session()->get('candidate.selected_opportunity'); + $scopedSelection = is_array($selection) && ($selection['company'] ?? null) === $company && ($selection['account'] ?? null) === $account->id && is_int($selection['id'] ?? null) && $selection['id'] > 0; + $selected = $this->rememberOpportunity($request, $account, $scopedSelection ? (int) $selection['id'] : null); return $this->page($request, 'candidate/profile', ['companyId' => $company, 'email' => $account->email, 'profile' => $profiles->profile($account), - 'emailVerified' => $account->email_verified_at !== null, 'accessStatus' => $request->session()->get('candidate_access_status'), 'emailDeliveryAvailable' => app(CandidateAccess::class)->deliveryAvailable(), - 'opportunities' => $opportunities->items(), 'opportunityPagination' => ['prev' => $opportunities->previousPageUrl(), 'next' => $opportunities->nextPageUrl()], + 'emailVerified' => $account->email_verified_at !== null, 'accessStatus' => $scopedSelection && $selected === null ? 'opportunity_unavailable' : $request->session()->get('candidate_access_status'), 'emailDeliveryAvailable' => app(CandidateAccess::class)->deliveryAvailable(), + 'opportunities' => $opportunities->items(), 'opportunityPagination' => ['prev' => $opportunities->previousPageUrl(), 'next' => $opportunities->nextPageUrl()], 'selectedOpportunity' => $selected, 'submissions' => Schema::hasTable('candidate_submissions') ? DB::table('candidate_submissions')->where('tenant_id', $company)->where('candidate_account_id', $account->id)->latest('id')->limit(20)->get(['id', 'reference', 'created_at']) : []]); } @@ -131,7 +137,7 @@ class CandidatePortalController extends Controller abort_unless($account !== null, 401); $profiles->event($account, 'account.logout', []); Auth::guard('candidate')->logout(); - $request->session()->forget('candidate.session_version'); + $request->session()->forget(['candidate.session_version', 'candidate.selected_opportunity']); $request->session()->regenerate(); $request->session()->regenerateToken(); Inertia::clearHistory(); @@ -139,6 +145,23 @@ class CandidatePortalController extends Controller return redirect('/candidaturas/'.$company); } + /** @return array{id:int,title:string}|null */ + private function rememberOpportunity(Request $request, CandidateAccount $account, ?int $id): ?array + { + $request->session()->forget('candidate.selected_opportunity'); + if ($id === null) { + return null; + } + try { + $opportunity = app(CandidateSubmissions::class)->opportunity($account->tenant_id, $id); + } catch (NotFoundHttpException|ModelNotFoundException) { + return null; + } + $request->session()->put('candidate.selected_opportunity', ['company' => $account->tenant_id, 'account' => $account->id, 'id' => $id]); + + return ['id' => $id, 'title' => $opportunity['title']]; + } + private function account(Request $request, int $company): ?CandidateAccount { $account = Auth::guard('candidate')->user(); @@ -205,6 +228,10 @@ class CandidatePortalController extends Controller $account = $this->account($request, $company); abort_unless($account !== null, 401); $id = $submissions->submit($account, $process, $request->all()); + $selection = $request->session()->get('candidate.selected_opportunity'); + if (is_array($selection) && ($selection['company'] ?? null) === $company && ($selection['account'] ?? null) === $account->id && ($selection['id'] ?? null) === $process) { + $request->session()->forget('candidate.selected_opportunity'); + } return redirect('/candidaturas/'.$company.'/submissoes/'.$id); } diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md index 372c11a..9e13c23 100644 --- a/docs/desenvolvimento.md +++ b/docs/desenvolvimento.md @@ -1,5 +1,11 @@ # Desenvolvimento do SGC +## Escolha preservada no registo e preparação do perfil, 30 de Outubro de 2026 + +A escolha pública segue agora tanto para a entrada como para o registo. Uma nova conta conserva uma referência à oportunidade na sessão, associada à identidade e empresa; não copia regras ou dados internos para essa referência. O perfil permite retomar o formulário depois de preparar os dados e confirmar o email, sem submissão automática. A criação da conta mantém o destino de preparação do perfil. Cada consulta reconfirma publicação, prazo e empresa; uma oportunidade retirada deixa de aparecer e produz informação genérica de indisponibilidade. Sessões com outra identidade/empresa ou referência incompleta não conservam o atalho. Sair ou concluir explicitamente a submissão remove a escolha. A confirmação de email e as restantes validações da candidatura são mantidas. + +Validação final SQLite: 28 testes/457 asserções. PostgreSQL descartável: 68 testes/1484 asserções, incluindo o percurso registo → perfil → tentativa não confirmada recusada → submissão explícita após confirmação fictícia. Base descartável removida, sem contas, mensagens ou candidaturas reais no domínio. A verificação adicional de referência de sessão incompleta foi exercida em SQLite/análise estática. Compilação concluída com código zero; PHPStan sem erros, tipos, Pint/lint e frontend dirigidos registados na prova `proofs/candidate-registration-selection.json`. Sem novas migrações. Incremento apenas local, posterior a f5f5649; produção continua em bf84575. Conferência visual ainda pendente. + ## Oportunidade escolhida preservada na entrada, 30 de Outubro de 2026 O botão de entrada de cada oportunidade guarda a escolha no formulário de login e apresenta o título escolhido. Após autenticação válida, o servidor volta a conferir publicação, empresa e prazo e encaminha para o formulário dessa oportunidade. Não aceita URLs de retorno fornecidos pelo cliente. Um identificador inexistente, de outra empresa, retirado ou expirado encaminha para o perfil com mensagem genérica de indisponibilidade. A entrada não envia a candidatura, não modifica o perfil e não dispensa confirmação de email ou requisitos documentais. Criar uma nova conta mantém o percurso de preparação do perfil anteriormente existente. diff --git a/docs/proofs/candidate-registration-selection-pg.json b/docs/proofs/candidate-registration-selection-pg.json new file mode 100644 index 0000000..72a2236 --- /dev/null +++ b/docs/proofs/candidate-registration-selection-pg.json @@ -0,0 +1,16 @@ +{ + "checked_at": "2026-10-30T19:21:10+00:00", + "database": "disposable PostgreSQL", + "application_role_reused_without_privilege_change": true, + "result": "passed", + "test_files": [ + "CandidateSubmissionsTest", + "PeopleHiringTest", + "CandidatePortalTest", + "CandidateDocumentFormatsTest" + ], + "original_evaluation_untouched": true, + "database_removed": true, + "tests": 68, + "assertions": 1484 +} diff --git a/docs/proofs/candidate-registration-selection.json b/docs/proofs/candidate-registration-selection.json new file mode 100644 index 0000000..7149744 --- /dev/null +++ b/docs/proofs/candidate-registration-selection.json @@ -0,0 +1,42 @@ +{ + "checked_at": "2026-10-30T19:22:25.916372+00:00", + "base_commit": "f5f5649", + "scope": [ + "selected public opportunity preserved through new candidate registration and profile preparation", + "session choice scoped to company and account", + "fresh publication and deadline check on profile reads", + "selection removed on logout account mismatch withdrawal and successful submission", + "no automatic application or email verification bypass" + ], + "module_sqlite": { + "tests": 28, + "assertions": 457, + "passed": true + }, + "postgresql": { + "tests": 68, + "assertions": 1484, + "passed": true, + "disposable_database_removed": true + }, + "frontend": { + "tests": 11, + "passed": true + }, + "phpstan_errors": 0, + "typescript": true, + "pint": true, + "targeted_lint": true, + "build": { + "exit_code": 0, + "completed": true + }, + "new_migrations": false, + "production_installed": false, + "visual_review": "pending; connector unavailable", + "source_sha256": { + "app/Http/Controllers/CandidatePortalController.php": "b54e01ef8f1013925a13c854945f60f8e360a090aa048d5627878c853c10e22d", + "resources/js/pages/candidate/entry.tsx": "35429f08b1ce635545284b4804bde3382891f1007ff9bb74536df6a8ccc7f2cb", + "resources/js/pages/candidate/profile.tsx": "f0ddeb382185c836fb03652b3ab498ef4a9651175ca4fcb7ff4a659d0f6bcc3a" + } +} diff --git a/resources/js/pages/candidate/entry.tsx b/resources/js/pages/candidate/entry.tsx index 66212f4..a8ffe2e 100644 --- a/resources/js/pages/candidate/entry.tsx +++ b/resources/js/pages/candidate/entry.tsx @@ -32,6 +32,7 @@ export default function Entry({ password_confirmation: '', portal_version: portal.version, notice_acknowledged: false, + opportunity_id: null as number | null, }); const credentials = (form: typeof login, prefix: string) => ( <> @@ -114,9 +115,10 @@ export default function Entry({ - login.setData('opportunity_id', item.id) - } + onClick={() => { + login.setData('opportunity_id', item.id); + register.setData('opportunity_id', item.id); + }} > Entrar para preparar a candidatura diff --git a/resources/js/pages/candidate/profile.tsx b/resources/js/pages/candidate/profile.tsx index d3f199b..4ea42ce 100644 --- a/resources/js/pages/candidate/profile.tsx +++ b/resources/js/pages/candidate/profile.tsx @@ -18,6 +18,7 @@ export default function Profile({ accessStatus, opportunities = [], opportunityPagination, + selectedOpportunity, submissions = [], }: { companyId: number; @@ -28,6 +29,7 @@ export default function Profile({ accessStatus?: string | null; opportunities?: { id: number; title: string; closes_on: string }[]; opportunityPagination?: { prev: string | null; next: string | null }; + selectedOpportunity?: { id: number; title: string } | null; submissions?: { id: number; reference: string; created_at: string }[]; }) { const form = useForm({ @@ -41,6 +43,24 @@ export default function Profile({ <>
+ {selectedOpportunity && ( + + )}

Oportunidades publicadas

diff --git a/tests/Feature/CandidateSubmissionsTest.php b/tests/Feature/CandidateSubmissionsTest.php index 49aaaef..33db147 100644 --- a/tests/Feature/CandidateSubmissionsTest.php +++ b/tests/Feature/CandidateSubmissionsTest.php @@ -1,6 +1,7 @@ count())->toBe(0); }); +test('selected opportunity survives registration and profile preparation until an explicit verified submission', function () { + $this->post($this->base.'/sair')->assertRedirect(); + $this->postJson($this->base.'/registar', [...$this->credentials, 'email' => 'selected@example.invalid', 'opportunity_id' => $this->process->id])->assertRedirect($this->base.'/perfil'); + $this->get($this->base.'/perfil')->assertInertia(fn (Assert $p) => $p->where('selectedOpportunity.id', $this->process->id)->where('profile.version', 0)->where('emailVerified', false)); + $this->postJson($this->base.'/perfil', $this->profile)->assertRedirect(); + $this->get($this->base.'/perfil')->assertInertia(fn (Assert $p) => $p->where('selectedOpportunity.id', $this->process->id)->where('profile.version', 1)); + $this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertForbidden(); + expect(DB::table('candidate_submissions')->count())->toBe(0); + $new = CandidateAccount::query()->where('email', 'selected@example.invalid')->firstOrFail(); + $new->setAttribute('email_verified_at', now())->save(); + $this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertRedirect()->assertSessionMissing('candidate.selected_opportunity'); + expect(DB::table('candidate_submissions')->count())->toBe(1); +}); + +test('selection is cleared on withdrawal logout and account mismatch and malformed registration cannot create an account', function () { + $this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => $this->process->id])->assertRedirect(); + app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 1, 'active' => false]); + $this->get($this->base.'/perfil')->assertInertia(fn (Assert $p) => $p->where('selectedOpportunity', null)->where('accessStatus', 'opportunity_unavailable'))->assertSessionMissing('candidate.selected_opportunity'); + $this->withSession(['candidate.selected_opportunity' => ['company' => $this->tenant->id, 'account' => $this->account->id + 999, 'id' => $this->process->id]]) + ->get($this->base.'/perfil')->assertInertia(fn (Assert $p) => $p->where('selectedOpportunity', null))->assertSessionMissing('candidate.selected_opportunity'); + $this->post($this->base.'/sair')->assertSessionMissing('candidate.selected_opportunity'); + $count = CandidateAccount::query()->count(); + $this->postJson($this->base.'/registar', [...$this->credentials, 'email' => 'invalid-selection@example.invalid', 'opportunity_id' => 'https://example.invalid'])->assertUnprocessable(); + expect(CandidateAccount::query()->count())->toBe($count); +}); + test('electronic submission returns a scoped receipt and preserves the actual profile and answers', function () { $this->get($this->base.'/oportunidades/'.$this->process->id)->assertInertia(fn (Assert $p) => $p->where('opportunity.description', 'Descrição pública fictícia da oportunidade.')->missing('opportunity.purpose')->missing('opportunity.selection')); $url = $this->base.'/oportunidades/'.$this->process->id; diff --git a/tests/frontend/candidate-portal.test.tsx b/tests/frontend/candidate-portal.test.tsx index 4b339ab..f916849 100644 --- a/tests/frontend/candidate-portal.test.tsx +++ b/tests/frontend/candidate-portal.test.tsx @@ -135,3 +135,31 @@ test('candidate profile renders geography and retained version without internal expect(html).not.toContain('Aprovar'); expect(html).not.toContain('/pessoas'); }); + +test('a newly registered candidate can resume the selected opportunity after preparing the profile without automatic submission', () => { + const initialPage = { + ...page, + component: 'candidate/profile', + props: { + ...page.props, + companyId: 1, + email: 'new@example.invalid', + selectedOpportunity: { id: 7, title: 'Serviço escolhido' }, + profile: { + version: 0, + data: { + name: '', + phone: '', + summary: '', + availability: { province: '', district: '', locality: '' }, + }, + }, + }, + }; + const html = renderToString( + , + ); + expect(html).toContain('Serviço escolhido'); + expect(html).toContain('/candidaturas/1/oportunidades/7'); + expect(html).toContain('A candidatura só será enviada quando confirmar'); +});