Paginar todos os comprovativos da propria conta de candidato
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-30 21:25:19 +02:00
parent 86b31f2840
commit 3b1ef2031c
8 changed files with 149 additions and 4 deletions
@@ -110,8 +110,9 @@ class CandidatePortalController extends Controller
return redirect('/candidaturas/'.$company); return redirect('/candidaturas/'.$company);
} }
$profiles->event($account, 'profile.consulted', []); $profiles->event($account, 'profile.consulted', []);
$filter = $request->validate(['opportunities_page' => 'sometimes|integer|min:1']); $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'); $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'); $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; $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); $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), 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(), '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, '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 public function save(Request $request, int $company, CandidateProfiles $profiles): Response
+12
View File
@@ -16,6 +16,18 @@ use stdClass;
class CandidateSubmissions 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. /** Only explicitly published, current and open opportunities; no internal process fields.
* @return LengthAwarePaginator<int, stdClass> * @return LengthAwarePaginator<int, stdClass>
*/ */
+6
View File
@@ -1,5 +1,11 @@
# Desenvolvimento do SGC # 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 ## 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. 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"
}
}
+33 -1
View File
@@ -20,6 +20,7 @@ export default function Profile({
opportunityPagination, opportunityPagination,
selectedOpportunity, selectedOpportunity,
submissions = [], submissions = [],
submissionPagination,
}: { }: {
companyId: number; companyId: number;
email: string; email: string;
@@ -30,7 +31,17 @@ export default function Profile({
opportunities?: { id: number; title: string; closes_on: string }[]; opportunities?: { id: number; title: string; closes_on: string }[];
opportunityPagination?: { prev: string | null; next: string | null }; opportunityPagination?: { prev: string | null; next: string | null };
selectedOpportunity?: { id: number; title: 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({ const form = useForm({
version: profile.version, version: profile.version,
@@ -98,10 +109,31 @@ export default function Profile({
className="underline" className="underline"
href={`/candidaturas/${companyId}/submissoes/${item.id}`} href={`/candidaturas/${companyId}/submissoes/${item.id}`}
> >
{item.title ? `${item.title} · ` : ''}
{item.reference} · {item.created_at} {item.reference} · {item.created_at}
</Link> </Link>
</p> </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> </section>
<main className="mx-auto max-w-3xl space-y-6 p-6"> <main className="mx-auto max-w-3xl space-y-6 p-6">
<p>SGC · Área do candidato</p> <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); 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 () { 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')); $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; $url = $this->base.'/oportunidades/'.$this->process->id;
+16
View File
@@ -108,6 +108,19 @@ test('candidate profile renders geography and retained version without internal
...page.props, ...page.props,
companyId: 1, companyId: 1,
email: 'test@example.invalid', 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', accessStatus: 'opportunity_unavailable',
profile: { profile: {
version: 3, 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="Distrito fictício"');
expect(html).toContain('value="Localidade fictícia"'); expect(html).toContain('value="Localidade fictícia"');
expect(html).toContain('Guardar nova versão do perfil'); 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).toContain('a oportunidade escolhida já não está disponível');
expect(html).not.toContain('Aprovar'); expect(html).not.toContain('Aprovar');
expect(html).not.toContain('/pessoas'); expect(html).not.toContain('/pessoas');