Retomar a oportunidade escolhida apos a entrada do candidato
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user