This commit is contained in:
@@ -110,8 +110,9 @@ class CandidatePortalController extends Controller
|
||||
return redirect('/candidaturas/'.$company);
|
||||
}
|
||||
$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');
|
||||
$filter = $request->validate(['opportunities_page' => 'sometimes|integer|min:1|max:2147483647', 'submissions_page' => 'sometimes|integer|min:1|max:2147483647']);
|
||||
$opportunities = app(CandidateSubmissions::class)->publicOpportunities($company, (int) ($filter['opportunities_page'] ?? 1))->withPath('/candidaturas/'.$company.'/perfil')->appends(array_intersect_key($filter, ['submissions_page' => true]));
|
||||
$receipts = app(CandidateSubmissions::class)->receipts($account, (int) ($filter['submissions_page'] ?? 1))->appends(array_intersect_key($filter, ['opportunities_page' => true]));
|
||||
$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);
|
||||
@@ -119,7 +120,7 @@ class CandidatePortalController extends Controller
|
||||
return $this->page($request, 'candidate/profile', ['companyId' => $company, 'email' => $account->email, 'profile' => $profiles->profile($account),
|
||||
'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']) : []]);
|
||||
'submissions' => $receipts->items(), 'submissionPagination' => ['prev' => $receipts->previousPageUrl(), 'next' => $receipts->nextPageUrl(), 'total' => $receipts->total()]]);
|
||||
}
|
||||
|
||||
public function save(Request $request, int $company, CandidateProfiles $profiles): Response
|
||||
|
||||
@@ -16,6 +16,18 @@ use stdClass;
|
||||
|
||||
class CandidateSubmissions
|
||||
{
|
||||
/** @return LengthAwarePaginator<int, stdClass> */
|
||||
public function receipts(CandidateAccount $account, int $page = 1): LengthAwarePaginator
|
||||
{
|
||||
$path = '/candidaturas/'.$account->tenant_id.'/perfil';
|
||||
if (! Schema::hasTable('candidate_submissions')) {
|
||||
return (new LengthAwarePaginator([], 0, 12, $page))->withPath($path)->setPageName('submissions_page');
|
||||
}
|
||||
|
||||
return DB::table('candidate_submissions')->where('tenant_id', $account->tenant_id)->where('candidate_account_id', $account->id)
|
||||
->orderByDesc('id')->paginate(12, ['id', 'reference', 'created_at', 'snapshot->opportunity->title as title'], 'submissions_page', $page)->withPath($path);
|
||||
}
|
||||
|
||||
/** Only explicitly published, current and open opportunities; no internal process fields.
|
||||
* @return LengthAwarePaginator<int, stdClass>
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Desenvolvimento do SGC
|
||||
|
||||
## Histórico completo de comprovativos do candidato, 30 de Outubro de 2026
|
||||
|
||||
A área autenticada deixa de limitar a lista aos 20 comprovativos mais recentes. Tem paginação própria, contagem apenas da conta e empresa autenticadas e título extraído da oportunidade preservada na submissão, sem procurar o título actual do processo ou enviar o snapshot completo. A paginação de comprovativos conserva a página de oportunidades e vice-versa, através apenas dos parâmetros numéricos validados. Perfis e comprovativos anteriores mantêm-se compatíveis. Não foram introduzidos estados de contratação, alterações documentais, novos acessos ou migrações.
|
||||
|
||||
Verificação dirigida SQLite: 29 testes/488 asserções nos módulos de perfil e submissões, incluindo 21 comprovativos próprios e um de outra conta, acesso à segunda página, título preservado e projecção sem dados adicionais. Onze testes frontend, tipos, PHPStan sem erros, Pint/lint dirigidos; restantes resultados finais na prova `proofs/candidate-receipt-history.json`. Apenas local, produção ainda em bf84575. Conferência visual continua pendente por indisponibilidade do conector.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"checked_at": "2026-10-30T19:24:58+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": 69,
|
||||
"assertions": 1515
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"checked_at": "2026-10-30T19:25:16.477974+00:00",
|
||||
"base_commit": "86b31f2",
|
||||
"scope": [
|
||||
"all own receipts reachable beyond first twenty",
|
||||
"company and account isolated count and rows",
|
||||
"opportunity title from immutable receipt snapshot",
|
||||
"no full snapshot or account identifiers sent in list",
|
||||
"independent page parameters preserve other list position"
|
||||
],
|
||||
"module_sqlite": {
|
||||
"tests": 29,
|
||||
"assertions": 488,
|
||||
"passed": true
|
||||
},
|
||||
"postgresql": {
|
||||
"tests": 69,
|
||||
"assertions": 1515,
|
||||
"passed": true,
|
||||
"disposable_database_removed": true
|
||||
},
|
||||
"frontend": {
|
||||
"tests": 11,
|
||||
"passed": true
|
||||
},
|
||||
"typescript": true,
|
||||
"phpstan_errors": 0,
|
||||
"pint": true,
|
||||
"targeted_lint": true,
|
||||
"build": {
|
||||
"exit_code": 0,
|
||||
"completed": true
|
||||
},
|
||||
"new_migrations": false,
|
||||
"production_installed": false,
|
||||
"visual_review": "pending; unavailable connector",
|
||||
"full_suite": "accepted bf84575 full-suite proofs reused; not repeated",
|
||||
"source_sha256": {
|
||||
"app/Http/Controllers/CandidatePortalController.php": "d028b6683c87add545826480b3a097bf607c414d07a40e00f01f7a85605b3016",
|
||||
"app/Services/CandidateSubmissions.php": "22b19fd3f560f716a9512a3cc9d95c4a1c43891fae58d653e089eba374e2c529",
|
||||
"resources/js/pages/candidate/profile.tsx": "4c5f9140e816b35b484b657a17fdca29fc0b89198382dbe28810bcc062f06e38"
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export default function Profile({
|
||||
opportunityPagination,
|
||||
selectedOpportunity,
|
||||
submissions = [],
|
||||
submissionPagination,
|
||||
}: {
|
||||
companyId: number;
|
||||
email: string;
|
||||
@@ -30,7 +31,17 @@ export default function Profile({
|
||||
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 }[];
|
||||
submissions?: {
|
||||
id: number;
|
||||
reference: string;
|
||||
created_at: string;
|
||||
title?: string | null;
|
||||
}[];
|
||||
submissionPagination?: {
|
||||
prev: string | null;
|
||||
next: string | null;
|
||||
total: number;
|
||||
};
|
||||
}) {
|
||||
const form = useForm({
|
||||
version: profile.version,
|
||||
@@ -98,10 +109,31 @@ export default function Profile({
|
||||
className="underline"
|
||||
href={`/candidaturas/${companyId}/submissoes/${item.id}`}
|
||||
>
|
||||
{item.title ? `${item.title} · ` : ''}
|
||||
{item.reference} · {item.created_at}
|
||||
</Link>
|
||||
</p>
|
||||
))}
|
||||
{submissionPagination && (
|
||||
<p>
|
||||
{submissionPagination.total} candidatura(s) entregue(s).
|
||||
</p>
|
||||
)}
|
||||
<nav
|
||||
aria-label="Páginas dos meus comprovativos"
|
||||
className="flex gap-4"
|
||||
>
|
||||
{submissionPagination?.prev && (
|
||||
<Link href={submissionPagination.prev} preserveScroll>
|
||||
Comprovativos anteriores
|
||||
</Link>
|
||||
)}
|
||||
{submissionPagination?.next && (
|
||||
<Link href={submissionPagination.next} preserveScroll>
|
||||
Mais comprovativos
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</section>
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<p>SGC · Área do candidato</p>
|
||||
|
||||
@@ -136,6 +136,25 @@ test('selection is cleared on withdrawal logout and account mismatch and malform
|
||||
expect(CandidateAccount::query()->count())->toBe($count);
|
||||
});
|
||||
|
||||
test('all own receipts remain reachable with frozen titles and independent pagination without exposing another candidate', function () {
|
||||
$this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertRedirect();
|
||||
$receipt = (array) DB::table('candidate_submissions')->sole();
|
||||
$process = (array) DB::table('people_processes')->where('id', $this->process->id)->first();
|
||||
unset($receipt['id'], $process['id']);
|
||||
$snapshot = json_decode($receipt['snapshot'], true, 32, JSON_THROW_ON_ERROR);
|
||||
$snapshot['opportunity']['title'] = 'Título preservado no comprovativo';
|
||||
for ($index = 0; $index < 20; $index++) {
|
||||
$id = DB::table('people_processes')->insertGetId([...$process, 'operation_key' => (string) Str::uuid(), 'title' => 'Outro título actual do processo']);
|
||||
DB::table('candidate_submissions')->insert([...$receipt, 'people_process_id' => $id, 'operation_key' => (string) Str::uuid(), 'reference' => 'CAND-'.Str::uuid(), 'snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
$foreign = app(CandidateProfiles::class)->register($this->tenant->id, [...$this->credentials, 'email' => 'private-receipt@example.invalid']);
|
||||
DB::table('candidate_submissions')->insert([...$receipt, 'candidate_account_id' => $foreign->id, 'operation_key' => (string) Str::uuid(), 'reference' => 'CAND-PRIVADO']);
|
||||
$this->get($this->base.'/perfil')->assertInertia(fn (Assert $p) => $p->has('submissions', 12)->where('submissionPagination.total', 21)->where('submissions.0.title', 'Título preservado no comprovativo')->missing('submissions.0.snapshot')->missing('submissions.0.candidate_account_id'));
|
||||
$this->get($this->base.'/perfil?submissions_page=2&opportunities_page=2')->assertInertia(fn (Assert $p) => $p->has('submissions', 9)->where('submissionPagination.total', 21)
|
||||
->where('submissionPagination.prev', fn ($url) => str_contains($url, 'opportunities_page=2') && str_contains($url, 'submissions_page=1'))->where('submissionPagination.next', null));
|
||||
$this->getJson($this->base.'/perfil?submissions_page=-1')->assertUnprocessable();
|
||||
});
|
||||
|
||||
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,19 @@ test('candidate profile renders geography and retained version without internal
|
||||
...page.props,
|
||||
companyId: 1,
|
||||
email: 'test@example.invalid',
|
||||
submissions: [
|
||||
{
|
||||
id: 8,
|
||||
reference: 'CAND-FICTICIA',
|
||||
created_at: '2026-10-30',
|
||||
title: 'Título preservado do processo',
|
||||
},
|
||||
],
|
||||
submissionPagination: {
|
||||
prev: null,
|
||||
next: '/candidaturas/1/perfil?submissions_page=2',
|
||||
total: 21,
|
||||
},
|
||||
accessStatus: 'opportunity_unavailable',
|
||||
profile: {
|
||||
version: 3,
|
||||
@@ -131,6 +144,9 @@ 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('Título preservado do processo');
|
||||
expect(html).toContain('Mais comprovativos');
|
||||
expect(html).toContain('/candidaturas/1/perfil?submissions_page=2');
|
||||
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