diff --git a/app/Http/Controllers/CandidatePortalController.php b/app/Http/Controllers/CandidatePortalController.php
index d7deae9..19aabda 100644
--- a/app/Http/Controllers/CandidatePortalController.php
+++ b/app/Http/Controllers/CandidatePortalController.php
@@ -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
diff --git a/app/Services/CandidateSubmissions.php b/app/Services/CandidateSubmissions.php
index 2fc66bc..1c85eef 100644
--- a/app/Services/CandidateSubmissions.php
+++ b/app/Services/CandidateSubmissions.php
@@ -16,6 +16,18 @@ use stdClass;
class CandidateSubmissions
{
+ /** @return LengthAwarePaginator */
+ 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
*/
diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md
index 9e13c23..1e1e6cd 100644
--- a/docs/desenvolvimento.md
+++ b/docs/desenvolvimento.md
@@ -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.
diff --git a/docs/proofs/candidate-receipt-history-pg.json b/docs/proofs/candidate-receipt-history-pg.json
new file mode 100644
index 0000000..274a7d9
--- /dev/null
+++ b/docs/proofs/candidate-receipt-history-pg.json
@@ -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
+}
diff --git a/docs/proofs/candidate-receipt-history.json b/docs/proofs/candidate-receipt-history.json
new file mode 100644
index 0000000..14bfb77
--- /dev/null
+++ b/docs/proofs/candidate-receipt-history.json
@@ -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"
+ }
+}
diff --git a/resources/js/pages/candidate/profile.tsx b/resources/js/pages/candidate/profile.tsx
index 4ea42ce..6326320 100644
--- a/resources/js/pages/candidate/profile.tsx
+++ b/resources/js/pages/candidate/profile.tsx
@@ -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}
))}
+ {submissionPagination && (
+
+ {submissionPagination.total} candidatura(s) entregue(s).
+
+ )}
+
SGC · Área do candidato
diff --git a/tests/Feature/CandidateSubmissionsTest.php b/tests/Feature/CandidateSubmissionsTest.php
index 33db147..bbb5d07 100644
--- a/tests/Feature/CandidateSubmissionsTest.php
+++ b/tests/Feature/CandidateSubmissionsTest.php
@@ -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;
diff --git a/tests/frontend/candidate-portal.test.tsx b/tests/frontend/candidate-portal.test.tsx
index f916849..e1d8930 100644
--- a/tests/frontend/candidate-portal.test.tsx
+++ b/tests/frontend/candidate-portal.test.tsx
@@ -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');