Retomar a oportunidade escolhida apos a entrada do candidato

This commit is contained in:
2026-10-30 21:18:53 +02:00
parent c22846b2b1
commit f5f5649cfc
8 changed files with 136 additions and 2 deletions
@@ -16,6 +16,7 @@ use App\Services\CurrentActor;
use App\Services\OrganizationConfiguration;
use App\Services\RequisitionWorkflow;
use App\Services\SubmissionForms;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@@ -24,6 +25,7 @@ use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CandidatePortalController extends Controller
{
@@ -69,7 +71,8 @@ class CandidatePortalController extends Controller
public function login(Request $request, int $company, CandidateProfiles $profiles): Response
{
abort_unless($profiles->portal($company) !== null, 404);
$data = $request->validate(['email' => 'required|string|max:254', 'password' => 'required|string|max:128']);
$data = $request->validate(['email' => 'required|string|max:254', 'password' => 'required|string|max:128',
'opportunity_id' => 'nullable|integer|min:1|max:2147483647']);
$account = CandidateAccount::query()->where('tenant_id', $company)->where('email', mb_strtolower(trim($data['email'])))->first();
$dummy = Hash::make('candidate-unavailable-account');
$valid = Hash::check($data['password'], $account->password ?? $dummy);
@@ -82,6 +85,18 @@ class CandidatePortalController extends Controller
$request->session()->regenerate();
Inertia::clearHistory();
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');
}
+6
View File
@@ -1,5 +1,11 @@
# Desenvolvimento do SGC
## 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.
Verificação dirigida final SQLite: 26 testes/400 asserções. PostgreSQL descartável: 66 testes/1426 asserções sobre os percursos de candidatos e Pessoas, incluindo os novos guardas de destino; a mensagem final de indisponibilidade foi adicionalmente conferida em SQLite e no frontend. Dez testes frontend, PHPStan sem erros, tipos, Pint e lint dirigidos. Sem novas migrações ou instalação no domínio; conferência visual continua pendente. Provas integrais da release bf84575 reutilizadas, sem repetição de toda a suíte. A compilação e prova final constam em `proofs/candidate-opportunity-login.json`.
## Consulta de oportunidades antes do registo, 30 de Outubro de 2026
A entrada da área do candidato mostra oportunidades explicitamente publicadas, abertas e dentro do prazo, com título, tipo, data e descrição pública. Não expõe finalidade interna, regras, candidaturas, avaliações ou documentos. A consulta é paginada por empresa; a última versão da publicação controla a visibilidade, incluindo retirada. O fecho de novos registos mantém a consulta pública e a entrada das contas existentes. Preparar e enviar candidaturas continuam a exigir autenticação própria, perfil e confirmação de email conforme as regras já existentes.
@@ -0,0 +1,16 @@
{
"checked_at": "2026-10-30T19:18:12+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": 66,
"assertions": 1426
}
@@ -0,0 +1,43 @@
{
"checked_at": "2026-10-30T19:18:47.034145+00:00",
"base_commit": "c22846b",
"scope": [
"selected public opportunity carried into candidate login",
"fixed server-generated redirect after authentication",
"company publication deadline and unknown-id recheck",
"profile fallback with generic unavailable notice",
"no automatic submission or registration flow changes"
],
"module_sqlite": {
"tests": 26,
"assertions": 400,
"passed": true
},
"postgresql": {
"tests": 66,
"assertions": 1426,
"passed": true,
"disposable_database_removed": true,
"final_notice_additionally_checked_in_sqlite_and_frontend": true
},
"frontend": {
"tests": 10,
"status": "passed"
},
"phpstan_errors": 0,
"typescript": true,
"pint": true,
"lint": true,
"build": {
"exit_code": 0,
"completed": true
},
"new_migrations": false,
"production_installed": false,
"visual_review": "pending; unavailable connector",
"source_sha256": {
"app/Http/Controllers/CandidatePortalController.php": "7866cb1c29a85761906637e3caa15f334ee67de8c212ccf4c31f07d021c61b1b",
"resources/js/pages/candidate/entry.tsx": "3c5cae01a0ebd5c2fd65569be5d2e039cb8c3e92e784497bfe8df468bdf13bab",
"resources/js/pages/candidate/profile.tsx": "b13aea00c82e7e35a955d598a65b5b01b3725d50a39a4b38a0b10c4ac6ef6dd2"
}
}
+19 -1
View File
@@ -21,7 +21,11 @@ export default function Entry({
next_page_url: string | null;
};
}) {
const login = useForm({ email: '', password: '' });
const login = useForm({
email: '',
password: '',
opportunity_id: null as number | null,
});
const register = useForm({
email: '',
password: '',
@@ -110,6 +114,9 @@ export default function Entry({
<a
className="inline-block underline"
href="#candidate-login"
onClick={() =>
login.setData('opportunity_id', item.id)
}
>
Entrar para preparar a candidatura
</a>
@@ -138,6 +145,17 @@ export default function Entry({
</nav>
</section>
<section className="space-y-3" id="candidate-login">
{login.data.opportunity_id !== null && (
<p>
Oportunidade escolhida:{' '}
{
opportunities.data.find(
(item) =>
item.id === login.data.opportunity_id,
)?.title
}
</p>
)}
<h2 className="text-xl font-semibold">Entrar</h2>
<form
className="space-y-3"
+7
View File
@@ -121,6 +121,13 @@ export default function Profile({
temporária de confirmação.
</p>
)}
{accessStatus === 'opportunity_unavailable' && (
<p role="status">
Entrou na sua conta, mas a oportunidade escolhida já não
está disponível. Consulte as oportunidades publicadas
acima.
</p>
)}
<form
className="space-y-4"
onSubmit={(e) => {
@@ -82,6 +82,33 @@ test('public opportunity listing is paginated within the company and remains vis
$this->getJson($this->base.'?opportunities_page=invalid')->assertUnprocessable();
});
test('candidate login opens the selected published opportunity without accepting an arbitrary redirect', function () {
$this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => $this->process->id, 'return_to' => 'https://example.invalid/untrusted'])
->assertRedirect($this->base.'/oportunidades/'.$this->process->id);
$this->get($this->base.'/oportunidades/'.$this->process->id)->assertInertia(fn (Assert $p) => $p->where('opportunity.id', $this->process->id));
$this->postJson($this->base.'/entrar', [...$this->credentials, 'return_to' => 'https://example.invalid/untrusted'])->assertRedirect($this->base.'/perfil');
$other = Tenant::query()->create(['name' => 'Outra empresa do ensaio de destino']);
$otherAdmin = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]);
$foreignProcess = (array) DB::table('people_processes')->where('id', $this->process->id)->first();
$foreignPublication = (array) DB::table('candidate_opportunity_publications')->where('people_process_id', $this->process->id)->first();
unset($foreignProcess['id'], $foreignPublication['id']);
$foreignId = DB::table('people_processes')->insertGetId([...$foreignProcess, 'tenant_id' => $other->id, 'created_by' => $otherAdmin->id, 'operation_key' => (string) Str::uuid()]);
DB::table('candidate_opportunity_publications')->insert([...$foreignPublication, 'tenant_id' => $other->id, 'people_process_id' => $foreignId, 'created_by' => $otherAdmin->id]);
$this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => $foreignId])->assertRedirect($this->base.'/perfil');
expect(DB::table('candidate_submissions')->count())->toBe(0);
});
test('login rechecks selected publication and deadline and falls back to the profile for stale destinations', function () {
$this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => 'https://example.invalid/untrusted'])->assertUnprocessable();
$this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => 2147483647])->assertRedirect($this->base.'/perfil')->assertSessionHas('candidate_access_status', 'opportunity_unavailable');
app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 1, 'active' => false]);
$this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => $this->process->id])->assertRedirect($this->base.'/perfil');
app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 2]);
$this->travel(8)->days();
$this->postJson($this->base.'/entrar', [...$this->credentials, 'opportunity_id' => $this->process->id])->assertRedirect($this->base.'/perfil');
expect(DB::table('candidate_submissions')->count())->toBe(0);
});
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;
+2
View File
@@ -108,6 +108,7 @@ test('candidate profile renders geography and retained version without internal
...page.props,
companyId: 1,
email: 'test@example.invalid',
accessStatus: 'opportunity_unavailable',
profile: {
version: 3,
data: {
@@ -130,6 +131,7 @@ test('candidate profile renders geography and retained version without internal
expect(html).toContain('value="Distrito fictício"');
expect(html).toContain('value="Localidade fictícia"');
expect(html).toContain('Guardar nova versão do perfil');
expect(html).toContain('a oportunidade escolhida já não está disponível');
expect(html).not.toContain('Aprovar');
expect(html).not.toContain('/pessoas');
});