Preservar a oportunidade durante o registo e preparacao do perfil
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user