diff --git a/app/Http/Controllers/CandidatePortalController.php b/app/Http/Controllers/CandidatePortalController.php
index 97a67a0..9fa91a6 100644
--- a/app/Http/Controllers/CandidatePortalController.php
+++ b/app/Http/Controllers/CandidatePortalController.php
@@ -48,9 +48,11 @@ class CandidatePortalController extends Controller
$portal = $profiles->portal($company);
abort_unless($portal !== null, 404);
$tenant = Tenant::query()->findOrFail($company);
+ $filter = $request->validate(['opportunities_page' => 'sometimes|integer|min:1']);
return $this->page($request, 'candidate/entry', ['company' => ['id' => $company, 'name' => $tenant->name],
- 'portal' => ['version' => $portal->version, 'registration_open' => (bool) $portal->registration_open, 'notice' => $portal->notice]]);
+ 'portal' => ['version' => $portal->version, 'registration_open' => (bool) $portal->registration_open, 'notice' => $portal->notice],
+ 'opportunities' => app(CandidateSubmissions::class)->publicOpportunities($company, (int) ($filter['opportunities_page'] ?? 1))]);
}
public function register(Request $request, int $company, CandidateProfiles $profiles): Response
@@ -90,13 +92,12 @@ 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');
return $this->page($request, 'candidate/profile', ['companyId' => $company, 'email' => $account->email, 'profile' => $profiles->profile($account),
'emailVerified' => $account->email_verified_at !== null, 'accessStatus' => $request->session()->get('candidate_access_status'), 'emailDeliveryAvailable' => app(CandidateAccess::class)->deliveryAvailable(),
- 'opportunities' => Schema::hasTable('candidate_opportunity_publications') ? DB::table('candidate_opportunity_publications as p')->join('people_processes as r', 'r.id', '=', 'p.people_process_id')
- ->where('p.tenant_id', $company)->where('r.tenant_id', $company)->where('p.active', true)->where('r.status', 'open')->where('r.closes_on', '>=', today()->toDateString())
- ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('candidate_opportunity_publications as newer')->whereColumn('newer.people_process_id', 'p.people_process_id')->whereColumn('newer.version', '>', 'p.version'))
- ->orderBy('r.closes_on')->limit(20)->get(['r.id', 'r.title', 'r.closes_on']) : [],
+ 'opportunities' => $opportunities->items(), 'opportunityPagination' => ['prev' => $opportunities->previousPageUrl(), 'next' => $opportunities->nextPageUrl()],
'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']) : []]);
}
diff --git a/app/Services/CandidateSubmissions.php b/app/Services/CandidateSubmissions.php
index d450cf1..2fc66bc 100644
--- a/app/Services/CandidateSubmissions.php
+++ b/app/Services/CandidateSubmissions.php
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Models\CandidateAccount;
use App\Models\PeopleProcess;
use App\Models\User;
+use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
@@ -15,6 +16,25 @@ use stdClass;
class CandidateSubmissions
{
+ /** Only explicitly published, current and open opportunities; no internal process fields.
+ * @return LengthAwarePaginator
+ */
+ public function publicOpportunities(int $company, int $page = 1): LengthAwarePaginator
+ {
+ $path = '/candidaturas/'.$company;
+ if (! Schema::hasTable('candidate_opportunity_publications')) {
+ return (new LengthAwarePaginator([], 0, 12, $page))->withPath($path)->setPageName('opportunities_page');
+ }
+
+ return DB::table('candidate_opportunity_publications as p')->join('people_processes as r', 'r.id', '=', 'p.people_process_id')
+ ->where('p.tenant_id', $company)->where('r.tenant_id', $company)->where('p.active', true)
+ ->where('r.status', 'open')->where('r.closes_on', '>=', today()->toDateString())
+ ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('candidate_opportunity_publications as newer')
+ ->whereColumn('newer.people_process_id', 'p.people_process_id')->whereColumn('newer.tenant_id', 'p.tenant_id')->whereColumn('newer.version', '>', 'p.version'))
+ ->orderBy('r.closes_on')->orderBy('r.id')
+ ->paginate(12, ['r.id', 'r.title', 'r.kind', 'r.closes_on', 'p.description'], 'opportunities_page', $page)->withPath($path);
+ }
+
/** @param array $input */
public function publish(User $actor, int $processId, array $input): void
{
diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md
index 05aacdc..4cdfcad 100644
--- a/docs/desenvolvimento.md
+++ b/docs/desenvolvimento.md
@@ -1,5 +1,25 @@
# Desenvolvimento do SGC
+## Consulta de oportunidades antes do registo, 30 de Outubro de 2026
+
+A entrada da área do candidato mostra oportunidades explicitamente publicadas, abertas e dentro do prazo, com título, tipo, data e descrição pública. Não expõe finalidade interna, regras, candidaturas, avaliações ou documentos. A consulta é paginada por empresa; a última versão da publicação controla a visibilidade, incluindo retirada. O fecho de novos registos mantém a consulta pública e a entrada das contas existentes. Preparar e enviar candidaturas continuam a exigir autenticação própria, perfil e confirmação de email conforme as regras já existentes.
+
+A área autenticada reutiliza a mesma consulta e passa a ter paginação, em vez de omitir oportunidades depois das primeiras 20. Retirada a mensagem desactualizada de que o envio de documentos ainda não estava disponível. Não foram abertas inscrições, publicadas oportunidades ou criadas contas para testar no domínio.
+
+Verificação dirigida: 24 testes PHP/374 asserções em perfil e submissões; PostgreSQL descartável: 64 testes/1401 asserções, incluindo Pessoas e formatos de documentos. Dez testes frontend dirigidos, tipos, PHPStan sem erros, Pint e lint dirigidos aprovados. As provas integrais aceites da entrega bf84575 são reutilizadas, sem alegar nova execução de toda a suíte. Conferência visual tentada novamente: conector devolveu native pipe indisponível e browser host indisponível. Este incremento continua local e não faz parte da release bf84575 activa no domínio. Provas `proofs/candidate-public-opportunities.json` e `candidate-public-opportunities-pg.json`.
+
+## Entrega em produção confirmada — bf84575, 30 de Outubro de 2026
+
+O commit `bf84575d10d9063c2224945e6d5627da826148a9` está em main e activo no domínio, na release `20261030-190744-bf84575d10d9-KyKKDI71`. O serviço existente terminou com Result=success e ExecMainStatus=0. Esta confirmação actualiza as indicações históricas de instalação apenas local abaixo.
+
+O instalador foi corrigido para exigir HTTP 200 em `/dashboard`: o anterior teste da raiz recebia o redireccionamento público esperado e provocava rollback. A validação e o rollback do instalador foram mantidos. Configuração anterior salvaguardada antes da correcção.
+
+Delta reconciliado: 14 migrações, de 000028 a 000041, sem repetir as 30 já registadas. Backup fresco da base e armazenamento, restauro conferido e ensaio das migrações na cópia antes da transacção de produção. As 81 tabelas anteriores conservaram os seus dados nas colunas de origem; 18 novas tabelas conferidas vazias e schema final igual ao ensaio. Existem agora 44 migrações registadas. A migração 000028 acrescenta o parâmetro de IVA por empresa com valor inicial 16%, sem reescrever os valores e snapshots anteriores. Não foram criadas contas, abertas inscrições ou publicadas oportunidades automaticamente.
+
+Armazenamento privado em storage partilhado persistente, com escrita conferida para www-data. Limites específicos do domínio: ficheiro 50M, pedido PHP e Nginx 104M, até 20 ficheiros; configuração Nginx validada e recarregada. Os 2M/8M ainda apresentados pelo PHP CLI são os limites do terminal e não os parâmetros específicos do domínio. PHP 8.4 dispõe de DOM/ZIP/fileinfo; PHP 8.5 CLI continua sem DOM/ZIP e não foi usado na instalação.
+
+Conferência HTTP: 21 ecrãs públicos com componentes esperados, utilizador nulo e âmbito vazio; sete percursos privados protegidos. Prova `proofs/candidate-documents-production.json`. Conferência visual dirigida e ensaio com candidatura real no domínio continuam pendentes; não foram enviados ficheiros pessoais ou emails reais para os simular.
+
## CV e documentos na candidatura electrónica, 30 de Outubro de 2026
Recepção de documentos integrada no mesmo envio da candidatura. A obrigatoriedade, formatos (PDF, DOCX, JPG e PNG) e limite por ficheiro vêm do formulário preservado na preparação do processo, sem procurar a versão actual da empresa. Recusados documentos desconhecidos, obrigatórios em falta, tamanho excessivo e divergência de extensão/MIME/conteúdo. DOCX exige estrutura Word/XML válida, sem declarações de entidades/DOCTYPE, macros ou executáveis conhecidos, com limites de entradas e expansão do arquivo. A conferência de formato não constitui antivírus, prova de autenticidade, validação do mérito ou aprovação documental.
diff --git a/docs/proofs/candidate-documents-production.json b/docs/proofs/candidate-documents-production.json
new file mode 100644
index 0000000..97c8065
--- /dev/null
+++ b/docs/proofs/candidate-documents-production.json
@@ -0,0 +1,253 @@
+{
+ "checked_at": "2026-10-30T19:09:50.960374+00:00",
+ "commit": "bf84575d10d9063c2224945e6d5627da826148a9",
+ "release": "/var/www/compras-deploy/releases/20261030-190744-bf84575d10d9-KyKKDI71",
+ "deployment_service": "success",
+ "migration_count": 44,
+ "migrations_applied": 14,
+ "existing_tables_preserved": 81,
+ "new_tables_verified_empty": 18,
+ "backup": "/root/compras-release-backups/20261030T190740921286Z-bf84575",
+ "database_restore_verified": true,
+ "storage_restore_verified": true,
+ "private_storage_shared": true,
+ "web_user_can_write_private_uploads": true,
+ "nginx_validation_ok": true,
+ "site_upload_configuration": [
+ "\n client_max_body_size 104M;",
+ " fastcgi_param PHP_VALUE \"upload_max_filesize=50M\\npost_max_size=104M\\nmax_file_uploads=20\";"
+ ],
+ "cli_limits_apply_to_cli_only": true,
+ "application": {
+ "database_driver": "pgsql",
+ "migration_count": 44,
+ "candidate_migrations": [
+ "2026_10_30_000037_create_candidate_profiles",
+ "2026_10_30_000038_create_candidate_access_tokens",
+ "2026_10_30_000039_create_candidate_submissions",
+ "2026_10_30_000040_create_candidate_triage",
+ "2026_10_30_000041_create_candidate_submission_documents"
+ ],
+ "documents_table_exists": true,
+ "document_count": 0,
+ "public_navigation_enabled": true,
+ "candidate_disk_root": "/var/www/compras-deploy/releases/20261030-190744-bf84575d10d9-KyKKDI71/storage/app/candidate-documents"
+ },
+ "http": {
+ "checked_at": "2026-10-30T19:08:35.710090+00:00",
+ "results": [
+ {
+ "path": "/",
+ "status": 302,
+ "location": "https://compras.tempo.co.mz/dashboard"
+ },
+ {
+ "path": "/dashboard",
+ "status": 200,
+ "component": "dashboard",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/requisicoes",
+ "status": 200,
+ "component": "requisitions/index",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/requisicoes/criar",
+ "status": 200,
+ "component": "requisitions/form",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/fornecedores",
+ "status": 200,
+ "component": "suppliers/index",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/cotacoes",
+ "status": 200,
+ "component": "quotations/index",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/ordens",
+ "status": 200,
+ "component": "purchases/index",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/recepcoes",
+ "status": 200,
+ "component": "receipts/index",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/stock",
+ "status": 200,
+ "component": "stock/index",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/stock/catalogo",
+ "status": 200,
+ "component": "stock/catalogue",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/stock/operacoes",
+ "status": 200,
+ "component": "stock/operations",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/equipamentos",
+ "status": 200,
+ "component": "equipment/index",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/planeamento",
+ "status": 200,
+ "component": "planning/index",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/pessoas",
+ "status": 200,
+ "component": "people/index",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/orcamentos",
+ "status": 200,
+ "component": "budgets/index",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/facturas",
+ "status": 200,
+ "component": "invoices/index",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/pagamentos",
+ "status": 200,
+ "component": "payments/index",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/regras-investimento",
+ "status": 200,
+ "component": "investment/index",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/politica-admissao",
+ "status": 200,
+ "component": "purchases/admission-policy",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/politica-recomendacao",
+ "status": 200,
+ "component": "purchases/recommendation-policy",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/revisao-compras",
+ "status": 200,
+ "component": "purchase-controls/index",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/settings/appearance",
+ "status": 200,
+ "component": "settings/appearance",
+ "anonymous": true,
+ "data_scope": "none",
+ "version": "d57b6033713dbe71fe3664abe9f56f4a"
+ },
+ {
+ "path": "/pessoas/cadastro",
+ "status": 302,
+ "location": "https://compras.tempo.co.mz/login"
+ },
+ {
+ "path": "/pessoas/1",
+ "status": 302,
+ "location": "https://compras.tempo.co.mz/login"
+ },
+ {
+ "path": "/fornecedores/1/dados-bancarios",
+ "status": 302,
+ "location": "https://compras.tempo.co.mz/login"
+ },
+ {
+ "path": "/administracao/parametrizacao",
+ "status": 302,
+ "location": "https://compras.tempo.co.mz/login"
+ },
+ {
+ "path": "/relatorios",
+ "status": 302,
+ "location": "https://compras.tempo.co.mz/login"
+ },
+ {
+ "path": "/settings/profile",
+ "status": 302,
+ "location": "https://compras.tempo.co.mz/login"
+ },
+ {
+ "path": "/candidaturas/1/submissoes/1/documentos/cv",
+ "status": 401
+ }
+ ],
+ "public_pages_passed": 21,
+ "private_routes_protected": 7,
+ "server_activation_proof": "pending"
+ },
+ "visual_review": "pending",
+ "real_candidate_submission_in_production": "not performed"
+}
diff --git a/docs/proofs/candidate-public-opportunities-pg.json b/docs/proofs/candidate-public-opportunities-pg.json
new file mode 100644
index 0000000..1f4dc00
--- /dev/null
+++ b/docs/proofs/candidate-public-opportunities-pg.json
@@ -0,0 +1,16 @@
+{
+ "checked_at": "2026-10-30T19:14:38+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": 64,
+ "assertions": 1401
+}
diff --git a/docs/proofs/candidate-public-opportunities.json b/docs/proofs/candidate-public-opportunities.json
new file mode 100644
index 0000000..fa5455e
--- /dev/null
+++ b/docs/proofs/candidate-public-opportunities.json
@@ -0,0 +1,44 @@
+{
+ "checked_at": "2026-10-30T19:15:14.138147+00:00",
+ "base_commit": "bf84575d10d9063c2224945e6d5627da826148a9",
+ "scope": [
+ "public published opportunity discovery before candidate registration",
+ "current publication deadline company and process scope",
+ "public content minimisation",
+ "shared public and authenticated pagination",
+ "authenticated application and document guards preserved"
+ ],
+ "module_sqlite": {
+ "tests": 24,
+ "assertions": 374,
+ "passed": true
+ },
+ "postgresql": {
+ "tests": 64,
+ "assertions": 1401,
+ "passed": true,
+ "disposable_database_removed": true
+ },
+ "frontend": {
+ "tests": 10,
+ "passed": true
+ },
+ "typescript": true,
+ "phpstan_errors": 0,
+ "pint": true,
+ "targeted_lint": true,
+ "build": {
+ "exit_code": 0,
+ "completed": true
+ },
+ "full_suite": "reuse accepted bf84575 suite; not repeated",
+ "new_migrations": false,
+ "production_installed": false,
+ "visual_review": "blocked by unavailable native pipe and browser host",
+ "source_sha256": {
+ "app/Services/CandidateSubmissions.php": "63bebd4ccb04e9bc2ab19b0701567f082831814498e83cd7e9755ba2f550f6e4",
+ "app/Http/Controllers/CandidatePortalController.php": "e37a7df5933da0d9635e91180a027d1d00d5dd3d16efc15c8c36a3ad906f724d",
+ "resources/js/pages/candidate/entry.tsx": "1fb6c2f3ecd7153fe677c7c8164867df22f0cdc146c0bc32d6c82a0b61cd354e",
+ "resources/js/pages/candidate/profile.tsx": "3857fec4bb8c10164a13b710f95df916707ebb8e214a05117f1f530b0b7b34c5"
+ }
+}
diff --git a/resources/js/pages/candidate/entry.tsx b/resources/js/pages/candidate/entry.tsx
index 2725292..f4b069e 100644
--- a/resources/js/pages/candidate/entry.tsx
+++ b/resources/js/pages/candidate/entry.tsx
@@ -5,9 +5,21 @@ import { Button } from '@/components/ui/button';
export default function Entry({
company,
portal,
+ opportunities = { data: [], prev_page_url: null, next_page_url: null },
}: {
company: { id: number; name: string };
portal: { version: number; registration_open: boolean; notice: string };
+ opportunities?: {
+ data: {
+ id: number;
+ title: string;
+ kind: string;
+ closes_on: string;
+ description: string;
+ }[];
+ prev_page_url: string | null;
+ next_page_url: string | null;
+ };
}) {
const login = useForm({ email: '', password: '' });
const register = useForm({
@@ -61,10 +73,71 @@ export default function Entry({
Prepare o seu perfil e candidate-se às oportunidades
- publicadas. O envio de anexos ainda está em desenvolvimento.
- Criar um perfil não constitui candidatura ou contratação.
+ publicadas. Os documentos exigidos são indicados em cada
+ formulário. Criar um perfil não constitui candidatura ou
+ contratação.