From 3b1ef2031ce395d21d973d0e7a7ec6630f058db8 Mon Sep 17 00:00:00 2001 From: nala Date: Fri, 30 Oct 2026 21:25:19 +0200 Subject: [PATCH] Paginar todos os comprovativos da propria conta de candidato --- .../Controllers/CandidatePortalController.php | 7 +-- app/Services/CandidateSubmissions.php | 12 ++++++ docs/desenvolvimento.md | 6 +++ docs/proofs/candidate-receipt-history-pg.json | 16 +++++++ docs/proofs/candidate-receipt-history.json | 43 +++++++++++++++++++ resources/js/pages/candidate/profile.tsx | 34 ++++++++++++++- tests/Feature/CandidateSubmissionsTest.php | 19 ++++++++ tests/frontend/candidate-portal.test.tsx | 16 +++++++ 8 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 docs/proofs/candidate-receipt-history-pg.json create mode 100644 docs/proofs/candidate-receipt-history.json 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');