Preservar a oportunidade durante o registo e preparacao do perfil

This commit is contained in:
2026-10-30 21:22:40 +02:00
parent f5f5649cfc
commit 86b31f2840
8 changed files with 184 additions and 16 deletions
@@ -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);
}
+6
View File
@@ -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.
@@ -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
}
@@ -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"
}
}
+5 -3
View File
@@ -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({
<a
className="inline-block underline"
href="#candidate-login"
onClick={() =>
login.setData('opportunity_id', item.id)
}
onClick={() => {
login.setData('opportunity_id', item.id);
register.setData('opportunity_id', item.id);
}}
>
Entrar para preparar a candidatura
</a>
+20
View File
@@ -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({
<>
<Head title="O meu perfil" />
<section className="mx-auto max-w-3xl space-y-3 p-6">
{selectedOpportunity && (
<aside className="space-y-2 rounded-lg border p-4">
<h2 className="font-semibold">
Oportunidade escolhida: {selectedOpportunity.title}
</h2>
<p>
Prepare o perfil e confirme o email. A candidatura
só será enviada quando confirmar a submissão no
formulário.
</p>
<Link
className="underline"
href={`/candidaturas/${companyId}/oportunidades/${selectedOpportunity.id}`}
>
Continuar a preparar esta candidatura
</Link>
</aside>
)}
<h2 className="text-xl font-semibold">
Oportunidades publicadas
</h2>
@@ -1,6 +1,7 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\CandidateAccount;
use App\Models\Tenant;
use App\Models\User;
use App\Services\CandidateProfiles;
@@ -109,6 +110,32 @@ test('login rechecks selected publication and deadline and falls back to the pro
expect(DB::table('candidate_submissions')->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;
+28
View File
@@ -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(
<App initialPage={initialPage} initialComponent={Profile} />,
);
expect(html).toContain('Serviço escolhido');
expect(html).toContain('/candidaturas/1/oportunidades/7');
expect(html).toContain('A candidatura só será enviada quando confirmar');
});