This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user