diff --git a/.env.example b/.env.example index 62994f54..bb8c1476 100644 --- a/.env.example +++ b/.env.example @@ -1,105 +1,155 @@ +# Este arquivo é a fonte de variáveis de ambiente para o Docker Compose +# (docker-compose.yml injeta ./.env em backend e scraper-go via env_file). +# Copie para `.env` e preencha os segredos antes de subir o projeto. + +# --------------------------------------------------------------------------- # Runtime +# --------------------------------------------------------------------------- NODE_ENV=development PORT=3001 -# Base URLs +# --------------------------------------------------------------------------- +# URLs base +# --------------------------------------------------------------------------- APP_URL=http://localhost:3001 FRONTEND_URL=http://localhost:5173 -GO_SCRAPER_URL=http://localhost:8081 -# Session -# Use uma string longa e secreta em ambientes reais. -SESSION_SECRET=change-me-with-a-long-random-secret +# --------------------------------------------------------------------------- +# Sessão (backend) — obrigatória +# --------------------------------------------------------------------------- +# Qualquer string longa e aleatória. Gere uma com: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +SESSION_SECRET= -# Segurança / PII -# ENCRYPTION_MASTER_KEY deve ter 32 bytes em hex, ou seja, 64 caracteres hex. +# --------------------------------------------------------------------------- +# Segurança / criptografia de PII (backend) — obrigatórias +# --------------------------------------------------------------------------- +# ENCRYPTION_MASTER_KEY precisa ter 64 caracteres hexadecimais (32 bytes). +# Gere uma com o mesmo comando acima. ENCRYPTION_MASTER_KEY= ENCRYPTION_KEY_ID=default +# Qualquer string secreta não vazia (usada para gerar hashes pesquisáveis de e-mail/CPF). SEARCH_KEY= -# CORS / frontend access +# --------------------------------------------------------------------------- +# CORS (backend) +# --------------------------------------------------------------------------- +# Fonte da verdade das origens permitidas. Em produção, se vazio, cai apenas +# em *.candidate.app.br (localhost nunca entra por fallback em produção). CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174 -# Search filters -SEARCH_LOCATION=Brasil -SEARCH_GEO_ID=106057199 -SEARCH_LANGUAGE=pt -REMOTE_ONLY=false -JOB_TYPES=C,F -TIME_FILTER=r604800 -SEARCH_KEYWORDS=UX Designer,UI Designer,Product Manager,Product Owner +# --------------------------------------------------------------------------- +# Rate limit de autenticação (backend) — login e cadastro têm buckets próprios. +# Variável ausente ou inválida (<= 0 ou não numérica) usa o default indicado. +# --------------------------------------------------------------------------- +AUTH_RATE_LIMIT_IP_MAX=20 +AUTH_RATE_LIMIT_ACCOUNT_MAX=5 +AUTH_RATE_LIMIT_WINDOW_SECONDS=900 -# Scraping behavior -SCRAPER_MAX_CONCURRENCY=12 -SCRAPER_PROVIDER_MAX_CONCURRENCY=2 -SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES= -SCRAPER_RUN_LOCK_TTL=120s -SCRAPER_RUN_LOCK_RENEW_INTERVAL=30s -GOMAXPROCS=2 -GOMEMLIMIT=1500MiB -WAIT_BETWEEN_SEARCHES_MS=5000 -PAGE_TIMEOUT_MS=10000 -MAX_PAGES_PER_KEYWORD=5 - -# Database / cache +# --------------------------------------------------------------------------- +# Banco de dados / cache +# --------------------------------------------------------------------------- POSTGRES_USER=vagas POSTGRES_PASSWORD=vagas POSTGRES_DB=vagas -# Use esta URL quando backend/scraper estiverem rodando fora do Docker e o banco estiver exposto no host. +# Use esta URL quando backend/scraper rodarem fora do Docker e o banco/Valkey +# estiverem expostos no host (veja LOCAL_DEVELOPMENT.md para expor as portas). DATABASE_URL=postgresql://vagas:vagas@localhost:5432/vagas VALKEY_URL=redis://localhost:6379/0 -# Use estas URLs dentro de containers Docker Compose. -# DATABASE_URL=postgresql://vagas:vagas@postgres:5432/vagas -# VALKEY_URL=redis://valkey:6379/0 +# Dentro do Docker Compose, o backend/scraper-go já recebem +# VALKEY_URL=redis://valkey:6379/0 via `environment:` no docker-compose.yml, +# sobrescrevendo o valor acima — não precisa duplicar aqui. CACHE_TTL_MS=600000 REDIS_KEY_PREFIX=vagas-full -KEYWORDS_REDIS_KEY=vagas-full:keywords KEYWORDS_STORAGE_MODE=env -# Mantém GET /keywords ativo, mas bloqueia POST /keywords e publicação no kwsync. +# Liga/desliga POST /keywords no backend e o consumidor da fila +# scraper:keywords:pending no scraper-go (kwsync). Mantenha false em dev +# a menos que você esteja testando esse fluxo especificamente. KWSYNC_ENABLED=false -# Legacy flags kept for compatibility -HEADLESS=false -VIEWPORT_WIDTH=1280 -VIEWPORT_HEIGHT=800 - -# E-mail (módulo de e-mail transacional) -# EMAIL_API_KEY vazio => provider no-op (apenas loga, não envia). Preencha com a chave da Resend para enviar de verdade. +# --------------------------------------------------------------------------- +# E-mail transacional (backend) +# --------------------------------------------------------------------------- +# Vazio ⇒ provider no-op (apenas loga, não envia). Preencha com a chave da +# Resend para enviar de verdade. EMAIL_API_KEY= EMAIL_FROM_ADDRESS= EMAIL_FROM_NAME= -# Tentativas de reenvio na fila de e-mail (backoff exponencial). Default: 3. EMAIL_QUEUE_ATTEMPTS=3 -# Third-party API credentials +# --------------------------------------------------------------------------- +# Integração backend <-> scraper-go +# --------------------------------------------------------------------------- +# Usada pelos adapters goScraper.ts/goKeywords.ts (fluxo de scraping/keywords direto). +GO_SCRAPER_URL=http://localhost:8081 +# Usada pelo scraperClient nos endpoints administrativos /admin/scrapers/*. +# É uma variável distinta de GO_SCRAPER_URL, mesmo apontando ao mesmo serviço. +SCRAPER_URL=http://localhost:8081 +# Endereço em que o próprio scraper-go escuta (opcional, default :8081). +GO_SCRAPER_ADDR=:8081 + +# --------------------------------------------------------------------------- +# Observabilidade +# --------------------------------------------------------------------------- +PROMETHEUS_URL=http://localhost:9090 + +# --------------------------------------------------------------------------- +# OAuth (opcional — só necessário para testar login social) +# --------------------------------------------------------------------------- +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +LINKEDIN_CLIENT_ID= +LINKEDIN_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# --------------------------------------------------------------------------- +# Frontend / front_admin (usadas como build args pelo Docker Compose) +# --------------------------------------------------------------------------- +VITE_API_BASE_URL=http://localhost:3001 +VITE_API_URL=http://localhost:3001 +VITE_APP_ENV=development + +# --------------------------------------------------------------------------- +# scraper-go — concorrência e limites de execução +# --------------------------------------------------------------------------- +SCRAPER_MAX_CONCURRENCY=12 +SCRAPER_PROVIDER_MAX_CONCURRENCY=2 +SCRAPER_PROVIDER_CONCURRENCY_OVERRIDES= +SCRAPER_RUN_LOCK_TTL=120s +SCRAPER_RUN_LOCK_RENEW_INTERVAL=30s +GOMAXPROCS=2 +GOMEMLIMIT=1500MiB + +# --------------------------------------------------------------------------- +# scraper-go — fontes de vagas de terceiros (todas opcionais) +# --------------------------------------------------------------------------- ADZUNA_APP_ID= ADZUNA_APP_KEY= +ADZUNA_KEYWORD_SLOT_SIZE=30 JOOBLE_API_KEY= +JOOBLE_API_BASE=https://br.jooble.org/api +THEMUSE_API_KEY= LINKEDIN_KEYWORD_SLOT_SIZE=30 -ADZUNA_KEYWORD_SLOT_SIZE=30 + GUPY_ENABLED=true GUPY_RAW_DISCOVERY_ENABLED=true GUPY_FULL_SWEEP_ENABLED=true GUPY_FULL_REMOTE_SWEEP_ENABLED=true GUPY_QUERY_LIMIT=60 + INHIRE_ENABLED=true INHIRE_TENANTS_FILE=./internal/interfaces/inhireTenants.json INHIRE_ENRICH_DETAILS=false INHIRE_DETAILS_MODE=ambiguous INHIRE_DETAILS_TIMEOUT_MS=10000 + GREENHOUSE_ENABLED=true GREENHOUSE_COMPANIES_FILE=./internal/interfaces/greenhouseCompanies.json + LEVER_ENABLED=false LEVER_COMPANIES_FILE=./internal/interfaces/leverCompanies.json LEVER_INCLUDE_ALL_JOBS=true - -# OAuth credentials -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= -LINKEDIN_CLIENT_ID= -LINKEDIN_CLIENT_SECRET= -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 35564e34..a6790608 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,9 @@ -# Responsaveis padrao para todos os diretorios do repositorio. +# Responsáveis gerais pelo repositório. * @Benevanio @hltav -# Responsaveis por alteracoes dentro de /frontend. -/frontend/** @lima300 @nayarakarinesilva +# Frontend: responsáveis específicos pela área. +/frontend/** @pedrosilvaadev @nayarakarinesilva + +# Configurações do GitHub e CI/CD. + /.github/** @Benevanio + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..e0a448b1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,112 @@ +## Linear + +Issue: PAV-___ + +## Branch flow + +- [ ] Este PR é uma feature/fix/chore destinada a `develop`. +- [ ] Este PR não contém alteração direta ou fluxo indevido para `master`. +- [ ] A branch foi criada a partir da base prevista pelo fluxo do projeto. + +## Objetivo + +Descreva objetivamente o problema resolvido por esta PR. + +## Escopo + +O que faz parte desta entrega: + +- + +O que está fora do escopo: + +- + +## Resumo das alterações + +- + +## Arquivos e módulos afetados + +- + +## Como testar + +```bash +# comandos de validação +``` + +## Validation + +- [ ] Lint executado +- [ ] Testes relevantes executados +- [ ] Coverage permanece dentro do mínimo do projeto +- [ ] Build executado quando aplicável +- [ ] Validação manual realizada quando aplicável +- [ ] Documentação atualizada quando necessária + +## Impacto de banco / migration + +- [ ] Não altera schema nem migrations +- [ ] Altera schema ou migrations + +Detalhes: + +## Impacto em contratos/API + +- [ ] Não altera contratos +- [ ] Altera endpoint, payload, schema ou comportamento de API + +Detalhes: + +## Impacto de configuração / infraestrutura + +- [ ] Não altera configuração +- [ ] Altera env, Docker, filas, cache, serviços ou infraestrutura + +Detalhes: + +## Impacto de segurança + +- [ ] Sem impacto relevante +- [ ] Impacto de segurança avaliado + +Detalhes: + +## Impacto de dados pessoais / privacidade + +- [ ] Sem tratamento novo ou alteração de dados pessoais +- [ ] Impacto avaliado + +Detalhes: + +## Compatibilidade / dependências + +- [ ] Não depende de outra task/PR +- [ ] Existe dependência ou ordem de deploy/merge + +Detalhes: + +## Evidências + +Adicione logs, screenshots, links de execução ou outra evidência relevante. + +## Riscos + +- + +## Rollback + +Descreva como a alteração pode ser revertida com segurança. + +## Checklist final + +- [ ] Escopo limitado ao card +- [ ] Não inclui segredos, `.env`, certificados ou tokens +- [ ] Não inclui arquivos temporários, builds, caches ou artefatos desnecessários +- [ ] Não executa deploy como efeito desta PR +- [ ] Critérios de aceite do card foram conferidos individualmente +- [ ] Alterações de contrato possuem testes e documentação correspondentes +- [ ] Alterações em banco/migrations foram validadas quando aplicável + +> Consulte o guia de contribuição do projeto para as regras completas. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56b15587..80f00e0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: @@ -20,6 +22,15 @@ jobs: - name: Instalar dependencias (raiz) run: npm ci + - name: Validar mensagens de commit (commitlint) + # Backstop server-side: o hook local commit-msg pode ser pulado com `git commit --no-verify`, + # então o CI revalida todos os commits do PR antes de liberar o merge. + # Roda só em pull_request: commits de push para master/develop já chegaram via squash-merge + # do GitHub, cuja mensagem é o título do PR (padrão "[PAV-XXX] [Categoria] descrição", + # ver GUIA-LINEAR-GITHUB.md) — um formato diferente e fora do controle de quem contribui. + if: github.event_name == 'pull_request' + run: npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}" --verbose + - name: Workaround npm optional deps (rollup linux) run: npm i --no-save --workspace=frontend @rollup/rollup-linux-x64-gnu diff --git a/.specs/STATE.md b/.specs/STATE.md index 5c733b1e..3adf9e51 100644 --- a/.specs/STATE.md +++ b/.specs/STATE.md @@ -13,8 +13,10 @@ Active project-level architectural decisions (AD-NNN). Each design must conform ## Handoff -**Feature concluída:** `email-module` (PAV-76) — ✅ Done. -**Estado:** 11/11 tasks implementadas e commitadas na branch `feature/pav-76-modulo-email` (Batch A: 5 commits; Batch B: 6 commits). Verifier PASS (11/11 ACs, gate 529/0, sensor 5/5 mutantes mortos). Relatório em `.specs/features/email-module/validation.md`. -**Entregue:** API interna `emailService.send/sendWelcome`, fila BullMQ/Valkey (ioredis), worker in-process, `MailProvider`+Resend+Noop, template `welcome` react-email com CTA (`FRONTEND_URL`), boas-vindas no registro (`CredentialsService.register`), docs no `BACKEND.md`. -**Pendências deixadas ao usuário:** (1) push + PR ainda NÃO feitos (a pedido); (2) envs de produção `EMAIL_API_KEY`/`EMAIL_FROM_ADDRESS`/`EMAIL_FROM_NAME` a comunicar ao dev quando for pra prod — sem elas o `NoopProvider` só loga. -**Próximo passo:** quando o usuário pedir, abrir PR da PAV-76. +**Feature concluída:** `job-detail-reorganizacao` (PAV-92) — ✅ Done. +**Estado:** implementada e commitada na branch `jovinull/pav-92-frontend` (4 commits: reorg de tiles, dedup do payload, feedback de notas, docs). Validação standalone PASS (8/8 ACs, gate 352/352 frontend, sensor de mutação raciocinado sem sobreviventes). Relatório em `.specs/features/job-detail-reorganizacao/validation.md`. +**Entregue:** `JobDetailModal` reorganizado — local/modalidade/nível/fonte/salário/match em tiles rotulados; bloco "Payload da vaga" renomeado para "Detalhes adicionais" e sem duplicar dado já mostrado; texto indicando que notas salvam ao fechar. Sem migração de modal pra página (critério do próprio card já resolvia isso) e sem novo contrato de backend. +**Pendências deixadas ao usuário:** push + PR ainda NÃO feitos (aguardando confirmação do usuário). +**Próximo passo:** quando o usuário pedir, abrir PR da PAV-92. + +**Feature anterior concluída:** `email-module` (PAV-76) — ✅ Done. 11/11 tasks, branch `feature/pav-76-modulo-email`, Verifier PASS (11/11 ACs, gate 529/0, sensor 5/5). Entregue: `emailService.send/sendWelcome`, fila BullMQ/Valkey, `MailProvider`+Resend+Noop, template `welcome`, boas-vindas no registro. Envs de produção `EMAIL_API_KEY`/`EMAIL_FROM_ADDRESS`/`EMAIL_FROM_NAME` a comunicar ao dev quando for pra prod. diff --git a/.specs/features/job-detail-reorganizacao/spec.md b/.specs/features/job-detail-reorganizacao/spec.md new file mode 100644 index 00000000..7734adb7 --- /dev/null +++ b/.specs/features/job-detail-reorganizacao/spec.md @@ -0,0 +1,106 @@ +# Reorganização do detalhe da candidatura — Specification + +_Linear: PAV-92 · "Frontend" (finalizar experiência do detalhe pós PAV-19/90)_ + +## Problem Statement + +O `JobDetailModal` já tem tudo que a PAV-19/90 entregou (status, notas, timeline), mas empilha tudo numa sequência vertical única e ainda expõe um bloco "Payload da vaga" que duplica local/salário/empresa/modalidade já mostrados em cima, com labels técnicas (`ID`, `URL`). O usuário não identifica rápido as informações principais e vê dado repetido/cru. + +## Goals + +- [ ] Informações principais (empresa, local, modalidade, nível, fonte, salário, match, status, link) reconhecíveis em tiles, sem depender do payload bruto. +- [ ] Payload bruto sem duplicar o que já tem representação amigável. +- [ ] Zero regressão em status, notas, timeline e link — mesmo comportamento, layout melhor. + +## Out of Scope + +| Item | Motivo | +| --- | --- | +| Migrar de modal para página dedicada | Modal já trata altura/scroll/responsividade (`max-h-[90vh]`, scroll interno, `max-w-3xl`); card só pede migrar se houver ganho claro — não há. | +| Anexos/links extras | Card explicitamente proíbe neste ticket. | +| Evento de nota na timeline | Sem contrato de backend definido; card proíbe. | +| Novo contrato de backend | Nada aqui muda API/schema. | + +--- + +## Assumptions & Open Questions + +| Assumption / decisão | Default escolhido | Rationale | Confirmado? | +| --- | --- | --- | --- | +| Onde fica o detalhe | Modal (não migra pra página) | Ver "Out of Scope" — critério do próprio card já resolve. | y (evidência de código) | +| Campos "principais" | empresa, local, modalidade (`job.type`), nível, fonte, salário, match, status, link | São exatamente os campos que hoje só aparecem via badge pequena ou via payload bruto. | n | +| O que sobra no payload | Só campos sem representação amigável hoje (ex. `postedAt`, `id`, `url` só se não coincidir com o link já mostrado) | Evita duplicar sem esconder dado real. | n | +| Feedback de salvar notas | Texto de estado ("Notas salvas ao fechar" / "Salvando notas...") — sem mudar o timing de persistência (ainda só ao fechar) | Card autoriza melhorar "se necessário", sem novo contrato; menor mudança de comportamento. | n | + +**Open questions:** nenhuma — resolvidas acima. + +--- + +## User Stories + +### P1: Reconhecer as informações principais sem ler payload cru ⭐ MVP + +**User Story**: Como candidato, quero ver empresa, local, modalidade, nível, fonte, salário, match e status num layout claro ao abrir o detalhe, sem precisar ler um bloco de dados técnicos. + +**Acceptance Criteria**: + +1. WHEN o modal abre THEN local, modalidade, nível, fonte, salário (ou "Não informado") e match score SHALL aparecer em tiles rotulados com label visível (ex. `Modalidade`) — não apenas como chip sem rótulo. +2. WHEN o modal abre THEN empresa (já é o subtítulo do modal) e status atual (já tem indicador colorido + controle de troca rotulado "Status") SHALL continuar visíveis sem precisar de um novo tile — não há duplicação a resolver aí. +3. WHEN o link principal da vaga é exibido THEN ele SHALL continuar abrindo `job.jobLink` em nova aba (comportamento inalterado). + +**Independent Test**: abrir o modal de uma vaga com todos os campos preenchidos e de uma vaga com campos ausentes (salário nulo); conferir que os tiles aparecem e "Não informado" cobre ausência, sem quebrar layout. + +--- + +### P2: Payload bruto sem duplicar dado já mostrado + +**User Story**: Como candidato, quero que o bloco de dados adicionais mostre só o que não apareceu em nenhum outro lugar do detalhe, pra não ver a mesma informação duas vezes. + +**Acceptance Criteria**: + +1. WHEN um campo do `rawPayload` corresponde a um campo já renderizado nos tiles principais (local, salário, empresa, modalidade) THEN ele SHALL ser omitido do bloco de dados adicionais. +2. WHEN sobram campos sem representação amigável THEN eles SHALL continuar visíveis, num bloco secundário rotulado, preservando o comportamento de link externo para valores que são URL. +3. WHEN não sobra nenhum campo após o filtro THEN o bloco secundário SHALL não renderizar (nem título vazio). + +**Independent Test**: vaga com `rawPayload` contendo `location`, `salary`, `company`, `modality` (duplicados) + `postedAt` (extra) → só `postedAt` aparece no bloco secundário. + +--- + +### P3: Feedback de que as notas são salvas + +**User Story**: Como candidato, quero saber que minha nota foi salva ao fechar o modal, já que hoje não há nenhuma indicação disso. + +**Acceptance Criteria**: + +1. WHEN o campo de notas é exibido THEN um texto auxiliar SHALL indicar que a nota é salva ao fechar o modal. + +--- + +## Edge Cases + +- WHEN `job.rawPayload` é `null`/`undefined`/`{}` THEN nenhum bloco de dados adicionais SHALL renderizar (como hoje). +- WHEN a vaga não está `isTracked` THEN a seção de timeline SHALL continuar oculta (comportamento inalterado). +- WHEN a timeline tem eventos, está carregando, vazia ou em erro THEN cada estado SHALL renderizar exatamente como hoje (sem regressão) — não é escopo de mudança visual profunda, só preservação. + +--- + +## Requirement Traceability + +| ID | Story | Status | +| --- | --- | --- | +| JDM-01 | P1 — tiles de info principal | Pending | +| JDM-02 | P1 — modalidade ganha tile próprio | Pending | +| JDM-03 | P1 — link inalterado | Pending | +| JDM-04 | P2 — payload sem duplicar | Pending | +| JDM-05 | P2 — bloco secundário só com sobra | Pending | +| JDM-06 | P2 — some quando vazio | Pending | +| JDM-07 | P3 — texto de "salva ao fechar" | Pending | +| JDM-08 | Regressão — status/timeline/link intactos | Pending | + +**Coverage:** 8 total, mapeado no plano de execução abaixo. + +## Success Criteria + +- [ ] Suíte do frontend verde, com testes novos cobrindo JDM-01..08. +- [ ] Nenhuma mudança de contrato de backend. +- [ ] Nenhuma regressão nos testes existentes de `JobDetailModal`/timeline. diff --git a/.specs/features/job-detail-reorganizacao/validation.md b/.specs/features/job-detail-reorganizacao/validation.md new file mode 100644 index 00000000..b16a464f --- /dev/null +++ b/.specs/features/job-detail-reorganizacao/validation.md @@ -0,0 +1,48 @@ +# Validation: job-detail-reorganizacao (PAV-92) + +**Modo:** standalone fallback (sem sub-agent — escopo pequeno, single-file, feito inline pelo mesmo agente; fresh-eyes reread do diff e da spec antes de escrever este relatório). + +**Diff range:** `42522ae..HEAD` (branch `jovinull/pav-92-frontend`, commits `99d6a1f`, `b5956a8`, `b3dd96f`, `d7068c3`). + +## Spec-anchored coverage check + +| AC | Evidência (`file:line`) | Asserção | Resultado esperado (spec) | Coberto? | +| --- | --- | --- | --- | --- | +| JDM-01 | `tests/unit/new_dashboard/jobDetailModal.test.tsx:44-55` | `getByText("Local"/"Modalidade"/"Nível"/"Fonte"/"Salário"/"Match")` + valores | tiles rotulados para local/modalidade/nível/fonte/salário/match | ✅ Sim | +| JDM-02 | `jobDetailModal.test.tsx:68-71` | `getByText("Globex")`, `getAllByText("Em entrevista")`, `getByLabelText(/^status$/i)).toHaveValue(...)` | empresa no subtítulo, status com indicador+controle, sem tile novo | ✅ Sim | +| JDM-03 | `jobDetailModal.test.tsx:179-181` | `link).toHaveAttribute("href", ...)/("target","_blank")` | link "Abrir vaga" inalterado | ✅ Sim | +| JDM-04 | `jobDetailModal.test.tsx:107-131` | `getAllByText("Local")).toHaveLength(1)` / `getAllByText("Modalidade")).toHaveLength(1)` | campo do payload que já tem tile não duplica | ✅ Sim | +| JDM-05 | `jobDetailModal.test.tsx:125-127` | `getByText("Publicado em")`/`getByText("2026-01-10")` | campo sem representação amigável continua visível num bloco próprio | ✅ Sim | +| JDM-06 | `jobDetailModal.test.tsx:134-152`, `154-165` | `queryByText("Detalhes adicionais")).not.toBeInTheDocument()` (payload só com dupes; sem rawPayload) | bloco não renderiza quando nada sobra | ✅ Sim | +| JDM-07 | `jobDetailModal.test.tsx:90-103` | `getByText(/salvas automaticamente ao fechar/i)` | texto indicando salvamento ao fechar | ✅ Sim | +| JDM-08 (regressão) | `tests/unit/new_dashboard/jobs.test.tsx:392-409` (não modificado no comportamento, só a asserção do heading renomeado — ver nota), `tests/unit/new_dashboard/page.test.tsx:445-457` (`atualiza o status...`, inalterado) | fluxo completo status→notas→close ainda dispara `onStatusChange`/`onNotesChange`/`onClose`; timeline não tocada, suíte cheia 352/352 verde | zero regressão em status, notas, timeline, link | ✅ Sim | + +**Spec-precision gaps:** nenhum. + +**Nota sobre JDM-08:** `jobs.test.tsx:392` tinha `expect(screen.getByText(/payload da vaga/i))` — o heading foi renomeado pra "Detalhes adicionais" e o payload de fixture (`baseJob.rawPayload = {description, url}`) agora é 100% redundante (`url === jobLink`), então o bloco não renderiza mais para esse fixture. A asserção foi invertida para `queryByText(...).not.toBeInTheDocument()`, o que é o comportamento correto pós-mudança (não uma alteração cosmética por conveniência — é o efeito direto e esperado da regra JDM-04 aplicada a esse fixture específico). + +## Gate + +- `npm run test --workspace=frontend`: **352/352 passed** (53 arquivos), incluindo os 8 novos testes de `jobDetailModal.test.tsx` e a suíte completa de `jobs.test.tsx`/`page.test.tsx` sem regressão. +- `npm run build --workspace=frontend`: build OK (mesmo aviso pré-existente de chunk >500kB, não relacionado). +- `npm run lint --workspace=frontend`: 0 erros (mesmos 3 warnings pré-existentes de `coverage/*` + 1 de `theme.toast.test.tsx`, nenhum novo). + +## Discrimination sensor (raciocínio, sem execução de mutação real — escopo pequeno) + +| Mutação hipotética | Teste que mata | Raciocínio | +| --- | --- | --- | +| Reverter `redundantPayloadKeys` para excluir só `"description"` | `jobDetailModal.test.tsx:130-131` (`getAllByText("Local")).toHaveLength(1)`) | Voltaria a duplicar "Local"/"Modalidade" → length vira 2 → falha | +| Remover o tile de Modalidade | `jobDetailModal.test.tsx:46-47` | `getByText("Modalidade")` deixa de existir → falha | +| Remover o texto de salvamento das notas | `jobDetailModal.test.tsx:101` | `getByText(/salvas automaticamente ao fechar/i)` não encontra nada → falha | +| Não filtrar valores `"Não informado"` do payload extra | `jobDetailModal.test.tsx:151` (bloco só com dupes) | campo residual apareceria e o bloco renderizaria → `queryByText` passaria a encontrar, teste falha | + +4 mutações de raciocínio, todas com teste que mataria — sem sobreviventes identificados. + +## Payload/conjunction rule + +- Cada tile (`InfoTile`) é verificado por rótulo **e** valor juntos (`getByText(label)` + `getByText(value)`), não só presença do componente. +- O bloco "Detalhes adicionais" é verificado tanto no caso positivo (label do campo residual + valor) quanto negativo (ausência do heading), cobrindo os dois lados da condição de filtro. + +## Verdict: PASS ✅ + +8/8 ACs (JDM-01..08) cobertas com evidência `file:line` e valor esperado batendo com a spec; nenhum gap de precisão; gate verde; sensor de mutação (raciocinado) sem sobreviventes; nenhuma asserção rasa identificada. diff --git a/BACKEND.md b/BACKEND.md index 9ab5a1a1..35cee3ac 100644 --- a/BACKEND.md +++ b/BACKEND.md @@ -29,12 +29,6 @@ npm run dev # inicia em modo de desenvolvimento npm start # inicia a API ``` -Rodar scraper (local): - -```bash -npm run scraper -``` - Testes: ```bash @@ -45,10 +39,11 @@ npm run test:watch Scripts relevantes em `backend/package.json`: - `start`, `dev`, `api` — iniciar servidor -- `scraper`, `scraper:watch` — executar scraper (index.ts / Go) -- `test`, `test:coverage`, `test:watch` — testes com Vitest +- `test`, `test:coverage`, `test:watch`, `validate` — testes com Vitest (`validate` roda `npm test`) - `db:generate`, `db:migrate`, `db:push` — comandos Drizzle +- `db:seed` — popula o banco local com usuários e dados de teste (`src/scripts/seed.ts`, idempotente; ver [LOCAL_DEVELOPMENT.md](LOCAL_DEVELOPMENT.md#61-seed-e-testes-da-api-local)) - `security:backfill-user-pii` — backfill de campos de PII criptografados +- `clear-cache` — limpa cache/índices no Valkey (`src/cache/clearCache.ts`) ## Arquitetura e módulos principais @@ -65,6 +60,7 @@ Módulos principais: - `src/modules/notifications` — notificações do usuário autenticado. - `src/modules/jobs` — busca, parsing de filtros, fallback pós-filtro e regras de matching/score de vagas. - `src/modules/admin` — usuários admin, permissões, scrapers, auditoria, dashboard e observabilidade. +- `src/modules/email` — envio de e-mails transacionais assíncronos (ver seção [Módulo de E-mail](#módulo-de-e-mail)). Adaptadores externos: @@ -73,10 +69,17 @@ Adaptadores externos: Database / Schemas (Drizzle): -- `src/db/schema/users.ts` — tabela `users`. -- `src/db/schema/credentials.ts` — credenciais (email, hash). +- `src/db/schema/users.ts` — tabela `users`. Campo `role` (enum `user_role`: `user` < `support` < `admin` < `super_admin`, hierarquia crescente) e `isBlocked`. Vários campos de PII (`email`, `firstName`, `lastName`, `displayName`, `phone`, `cpf`, `technologies`, `level`) existem em par: uma coluna em texto plano (legado/transição) e uma coluna `*Encrypted` com o valor cifrado (AES-256-GCM, `src/lib/security/encryption.ts`); `emailHash`/`cpfHash` guardam hash HMAC pesquisável (`src/lib/security/searchableHash.ts`) para permitir busca sem descriptografar. O fluxo de criação/atualização sempre grava a versão criptografada e zera a coluna plana. +- `src/db/schema/credentials.ts` — credenciais de login local (`email`, `emailHash`, `passwordHash`), 1:1 com `users` via `userId`. +- `src/db/schema/accounts.ts` — vínculos OAuth (`provider`, `providerAccountId`, tokens) associados a um `users.id`. - `src/db/schema/keywords.ts` — palavras-chave (fonte `user|scraper`). -- `src/db/schema/savedJobs.ts` — vagas salvas (`saved_jobs`) e enum `status`. +- `src/db/schema/savedJobs.ts` — vagas salvas (`saved_jobs`); campo `status` aceita `saved`, `applied`, `interviewing`, `rejected`, `accepted`; campo `notes` guarda anotação privada do usuário sobre a vaga. +- `src/db/schema/applicationEvents.ts` — `application_events`: histórico de mudança de status de uma vaga salva (`fromStatus`/`toStatus`), exposto em `GET /saved-jobs/:id/events`. +- `src/db/schema/applicationNotes.ts` — `application_notes`: múltiplas notas privadas por vaga salva (`content`, timestamps), uma tabela separada do campo legado `saved_jobs.notes` — CRUD completo em `/saved-jobs/:id/notes`. +- `src/db/schema/userPreferences.ts` — `user_preferences`: preferências de busca e checklist de carreira do usuário, criada automaticamente no registro. +- `src/db/schema/userNotifications.ts` — `user_notifications`: notificações in-app do usuário. +- `src/db/schema/auditLogs.ts` — `audit_logs`: trilha de ações administrativas (ator, ação, alvo, metadata). +- `src/db/schema/permissionRules.ts` — `permission_rules`: matriz de permissões (recurso/ação/role mínima) persistida no banco, além da matriz em código (`src/modules/admin/permissions/permissionMatrix.ts`). - Migrações e snapshots em `drizzle/`. Cache & Indexes: @@ -141,15 +144,18 @@ await emailService.sendWelcome({ email: "usuario@exemplo.com", name: "Ana" }); - `withSession` — integra `iron-session` (sessões + cookie `vagas_session`). - `requireAuth` — valida autenticação nas rotas que exigem usuário. -- `securityHeaders` — cabeçalhos de segurança. +- `securityHeaders` — cabeçalhos de segurança (ver seção **Segurança e criptografia**). - `cors` — configuração de CORS (opções em `src/middleware/cors.ts`). +- `rateLimit` — limitadores de tentativas em endpoints de autenticação (`src/middleware/rateLimit.ts`). +- `validate` — validação/normalização de `body`/`query`/`params` via schemas Zod. - `requestId` — correlação de requisições. - `metrics` — coleta de métricas Prometheus. +- `rateLimit` (`authIpRateLimiter`, `authAccountRateLimiter`) — limita tentativas de login por IP e por conta (`AUTH_RATE_LIMIT_*`). - `errorHandler` — tratamento centralizado de erros. ## Endpoints principais -Base: `/` +Base: `/api/v1` (prefixo oficial, `backend/src/app.ts`). As mesmas rotas seguem respondendo sem prefixo (ex.: `/auth/login` além de `/api/v1/auth/login`) como compatibilidade temporária para clientes ainda não migrados; `GET /health` responde nos dois formatos. Veja também a seção "Versionamento da API" do [README.md](README.md). - Sistema - `GET /health` — verifica disponibilidade (retorna `{ ok: true }`). @@ -162,9 +168,11 @@ Base: `/` - Credenciais (email/senha) - `POST /auth/register` — registra usuário (cria `users`, `credentials`, `userPreferences`) e inicia sessão. - - `POST /auth/login` — autentica e inicia sessão. + - `POST /auth/login` — autentica e inicia sessão. Sujeito a rate limit por IP e por conta (`AUTH_RATE_LIMIT_IP_MAX`, `AUTH_RATE_LIMIT_ACCOUNT_MAX`, `AUTH_RATE_LIMIT_WINDOW_SECONDS`). - `POST /auth/logout` — destroi sessão. - - `GET /auth/me` — retorna id do usuário autenticado. + - `GET /auth/me` — retorna `{ user }` com o registro completo do usuário autenticado (401 se a sessão for inválida/expirada). + - `GET /auth/connections` — lista provedores OAuth conectados ao usuário autenticado. + - `DELETE /auth/connections/:provider` — desconecta um provedor OAuth do usuário autenticado. - Usuários - `GET /users/profile` — retorna perfil do usuário autenticado. @@ -178,8 +186,8 @@ Base: `/` - Filtros aceitos incluem `keywords`, `family`, `technology`, `seniority`, `level`, `location`, `country`, `state`, `city`, `type`/`model`, `contract`/`contractType`/`jobTypes` e `matchSort`. - Keywords - - `GET /keywords` — lista keywords persistidas no banco. - - `POST /keywords` — enfileira uma keyword para processamento pelo serviço Go (retorna 202). + - `GET /keywords` — lista keywords do usuário autenticado. + - `POST /keywords` — se `KWSYNC_ENABLED=false` (padrão), retorna `403` (`{ ok:false, message: "Submissão de keywords por usuário está desabilitada." }`). Se habilitado, insere a keyword na tabela `keywords` (dedupe por `userId+keyword`) e publica na fila Valkey `scraper:keywords:pending` para o serviço Go processar (retorna 202). - Notificações - `GET /notifications` — lista notificações do usuário autenticado. @@ -190,11 +198,25 @@ Base: `/` - Vagas salvas (Saved Jobs) - `GET /saved-jobs` — lista vagas salvas do usuário. - `GET /saved-jobs/:id` — obtém vaga salva por id. + - `GET /saved-jobs/:id/events` — histórico de mudanças de status da vaga salva (tabela `application_events`). + - `GET /saved-jobs/:id/notes` — lista as notas privadas da vaga salva (tabela `application_notes`, mais de uma por vaga). + - `POST /saved-jobs/:id/notes` — cria uma nota (`{ content }`, 1–5000 caracteres). + - `PATCH /saved-jobs/:id/notes/:noteId` — atualiza o conteúdo de uma nota. + - `DELETE /saved-jobs/:id/notes/:noteId` — remove uma nota. - `POST /saved-jobs` — cria nova vaga salva. - - `PATCH /saved-jobs/:id` — atualiza vaga salva. + - `PATCH /saved-jobs/:id` — atualiza vaga salva (inclui `status` e o campo legado de nota única `notes`, distinto das notas em `/saved-jobs/:id/notes`). - `DELETE /saved-jobs/:id` — remove vaga salva. - Admin + + As rotas `/admin/*` são montadas por três routers distintos, cada um com uma role mínima diferente (`src/modules/admin/permissions/roles.ts`, hierarquia `user < support < admin < super_admin`). A matriz completa de recurso/ação/role fica em `src/modules/admin/permissions/permissionMatrix.ts` (também espelhada na tabela `permission_rules`). + + Role mínima `support` (`src/routes/support.routes.ts`): + - `GET /admin/dashboard` — métricas gerais (usuários, vagas coletadas, status do scraper). + - `GET /admin/scrapers`, `GET /admin/scrapers/status`, `GET /admin/scrapers/jobs`, `GET /admin/scrapers/jobs/count` — leitura de estado/dados do scraper. + - `GET /admin/observability/health` — healthcheck agregado dos serviços. + + Role mínima `admin` (`src/routes/admin.routes.ts`): - `GET /admin/users` — lista usuários. - `GET /admin/users/:id` — obtém usuário por id. - `PATCH /admin/users/:id/block` — bloqueia usuário. @@ -210,10 +232,16 @@ Base: `/` - `GET /admin/audit` — consulta logs de auditoria. - `GET /admin/permissions/rules` — lista regras de permissão. + Role mínima `super_admin` (`src/routes/superAdmin.routes.ts`): + - `PATCH /admin/users/:id/role` — altera a role de um usuário. + - `DELETE /admin/users/:id` — remove um usuário definitivamente. + - `PATCH /admin/permissions/rules` — atualiza a matriz de permissões. + - `DELETE /admin/jobs/cache` — limpa o cache/índice de vagas no Valkey. + Observações de segurança nas rotas: -- Rotas sob `/users`, `/jobs`, `/keywords`, `/notifications`, `/saved-jobs` e `/admin` usam `withSession` + `requireAuth` (quando aplicável). -- `auth` usa `withSession` para armazenar OAuth state e criar sessão. +- Rotas sob `/users`, `/jobs`, `/keywords`, `/notifications`, `/saved-jobs` e `/admin` usam `withSession` + `requireAuth` (quando aplicável); `/admin/*` adicionalmente exige `requireRole`/`requirePermission` conforme a tabela acima. +- `auth` usa `withSession` para armazenar OAuth state e criar sessão; `POST /auth/login` passa também por `authIpRateLimiter`/`authAccountRateLimiter`. ## Variáveis de ambiente importantes @@ -229,37 +257,88 @@ Definidas/consumidas em `src/config.ts` e outros módulos: - `JOB_TYPES` — filtros de tipo de vaga. - `TIME_FILTER` — filtro temporal (ex: `r604800`). - `DATABASE_URL` — conexão com Postgres. -- `VALKEY_URL` — endpoint do Valkey (cache e fila de e-mail via BullMQ). -- `FRONTEND_URL` — URL do frontend; reusada no CTA do e-mail de boas-vindas. +- `VALKEY_URL` — endpoint do Valkey (cache, fila de e-mail via BullMQ e fila de keywords do kwsync). +- `CACHE_TTL_MS` — TTL padrão (ms) do cache de vagas no Valkey. +- `KWSYNC_ENABLED` — padrão `false`. Liga/desliga tanto `POST /keywords` no backend quanto o consumidor da fila `scraper:keywords:pending` no scraper-go (ver [SCRAPER.md](SCRAPER.md)). +- `APP_URL` — URL base pública da aplicação (uso informativo/documental). +- `FRONTEND_URL` — URL do frontend; reusada no CTA do e-mail de boas-vindas e no redirect pós-OAuth. - `EMAIL_API_KEY` — chave da Resend (vazio ⇒ envio no-op logado). - `EMAIL_FROM_ADDRESS` — endereço remetente dos e-mails. - `EMAIL_FROM_NAME` — nome exibido do remetente. - `EMAIL_QUEUE_ATTEMPTS` — tentativas por job de e-mail (padrão 3). -- `GO_SCRAPER_URL` — URL do serviço Go que realiza scraping. +- `GO_SCRAPER_URL` — URL usada pelos adapters `goScraper.ts`/`goKeywords.ts` (fluxo de scraping/keywords direto). +- `SCRAPER_URL` — URL usada pelo `scraperClient` nos endpoints administrativos `/admin/scrapers/*` (`config.scraperUrl`). É uma variável **distinta** de `GO_SCRAPER_URL`, apontando ao mesmo serviço Go por um caminho de integração diferente. - `SESSION_SECRET` — senha para `iron-session` (obrigatória em produção). - `ENCRYPTION_MASTER_KEY`, `ENCRYPTION_KEY_ID`, `SEARCH_KEY` — criptografia e campos pesquisáveis de PII. +- `AUTH_RATE_LIMIT_IP_MAX`, `AUTH_RATE_LIMIT_ACCOUNT_MAX`, `AUTH_RATE_LIMIT_WINDOW_SECONDS` — limites de tentativas de login por IP/conta e janela (segundos) do rate limiter de `POST /auth/login`. - `CORS_ALLOWED_ORIGINS` — origens permitidas, incluindo `http://localhost:5173` e `http://localhost:5174` em desenvolvimento local com admin. - `PROMETHEUS_URL` — integração com Prometheus para rotas de observabilidade. - `PORT` — porta do servidor (padrão 3001). ## Segurança e criptografia -- Senhas armazenadas usando Argon2 (`argon2`), com opções configuradas no serviço de credenciais. -- Cookies de sessão `httpOnly` e `secure` quando NODE_ENV=production. +### Autenticação e sessão + +- Senhas com Argon2id (`argon2`), parâmetros `memoryCost: 65536`, `timeCost: 3`, `parallelism: 4` (`src/modules/auth/credentials.service.ts`). +- Cookies de sessão (`vagas_session`, via `iron-session`) `httpOnly` sempre; `secure` e `sameSite: "none"` quando `NODE_ENV=production`, `sameSite: "lax"` em desenvolvimento (`src/lib/session.ts`). +- Campos sensíveis de perfil (`email`, nome, telefone, CPF, tecnologias) são criptografados com AES-256-GCM (`ENCRYPTION_MASTER_KEY`) e indexados para busca via hash HMAC (`SEARCH_KEY`) — ver `src/lib/security/encryption.ts` e `src/lib/security/searchableHash.ts`. +- `toPublicUser` (`src/modules/users/users.mapper.ts`) remove os campos internos `*Encrypted`/`*Hash` antes de qualquer resposta JSON conter um `user` — apenas os campos decifrados (`email`, `firstName`, etc.) e os demais campos não sensíveis (`id`, `username`, `role`, `isBlocked`, timestamps) são expostos. + +### Rate limiting (`src/middleware/rateLimit.ts`) + +Limitadores por janela deslizante, com contador no Valkey quando `VALKEY_URL` está definido e fallback em memória caso contrário. Respostas incluem `RateLimit-Limit`/`RateLimit-Remaining`/`RateLimit-Reset`; ao estourar, `429` com `Retry-After`. Falha do backend de contagem responde `503` (fail-closed). + +| Rota | Limitadores | Chave | +| --- | --- | --- | +| `POST /auth/login` | `authIpRateLimiter`, `authAccountRateLimiter` | IP (hash) / e-mail (hash) | +| `POST /auth/register` | `authIpRateLimiter`, `authRegisterRateLimiter` | IP (hash) / e-mail (hash), bucket próprio | + +Configuração (variável ausente usa o default; valor `≤ 0` ou não numérico também cai no default): + +- `AUTH_RATE_LIMIT_IP_MAX` — máximo de tentativas por IP na janela. Default `20`. +- `AUTH_RATE_LIMIT_ACCOUNT_MAX` — máximo por e-mail na janela (login e cadastro têm buckets separados). Default `5`. +- `AUTH_RATE_LIMIT_WINDOW_SECONDS` — tamanho da janela em segundos. Default `900` (15 min). + +Pendente: aplicar rate limit ao endpoint de exportação de dados (LGPD) quando a PAV-41 for mergeada. + +### CORS (`src/middleware/cors.ts`) + +- `CORS_ALLOWED_ORIGINS` (lista separada por vírgula) é a fonte da verdade das origens permitidas. +- Sem a env: em `production` cai apenas nas origens de produção (`*.candidate.app.br`) e loga um aviso — `localhost` **nunca** entra no allowlist de produção por fallback. Fora de produção, o fallback inclui `http://localhost:5173` e `:5174`. +- `credentials: true`; métodos `GET, POST, PATCH, DELETE, OPTIONS`; headers `Content-Type, Authorization, X-Requested-With`; preflight cacheado por 24 h. Requisições sem header `Origin` (server-to-server, mesma origem) são permitidas. +- Origem fora do allowlist retorna `403` com `{ code: "FORBIDDEN", message: "Origem não permitida." }`. + +### Cabeçalhos de resposta (`src/middleware/securityHeaders.ts`) + +Aplicados a todas as respostas: + +- `X-Content-Type-Options: nosniff` +- `X-Frame-Options: DENY` +- `Referrer-Policy: strict-origin-when-cross-origin` +- `Permissions-Policy: camera=(), microphone=(), geolocation=()` +- `Strict-Transport-Security: max-age=31536000; includeSubDomains` — só sobre HTTPS (`req.secure`, resolvido via `trust proxy`) ou `NODE_ENV=production`. `preload` fica de fora de propósito (opt-in do time). +- `x-powered-by` desabilitado. +- CSP da API e dos frontends (Nginx/Vercel) é tratada em [SECURITY.md](SECURITY.md) (PAV-132). + +### Entrada e persistência + +- Corpo de requisição limitado a `16kb` (`express.json({ limit: "16kb" })`). +- Validação/normalização de entrada via schemas Zod (`middleware/validate`) nas rotas de auth, users, keywords e saved-jobs. +- Acesso ao banco via Drizzle (queries parametrizadas — sem concatenação de SQL). - Índices únicos e constraints no DB (ex: email/username/keyword uniques) definidos nas tabelas Drizzle. -- Campos sensíveis de perfil usam criptografia e hashes pesquisáveis onde aplicável. ## Integração com serviço Go - `goScraper.ts` faz POST em `${GO_SCRAPER_URL}/scrape` com `ScrapeParams` e valida `ScrapeResponse`. - `goKeywords.ts` consulta e publica keywords via endpoints do serviço Go (`/api/keywords`). - O backend lê os índices criados pelo scraper no Valkey, incluindo `scraper:jobs:keyword:*`, `scraper:jobs:family:*`, `scraper:jobs:technology:*` e `scraper:jobs:seniority:*`. -- Disparos administrativos usam `scraperClient` e preservam os códigos operacionais do serviço Go. O código `SCRAPER_ALREADY_RUNNING` é um conflito esperado; `SCRAPER_RUN_LOCK_UNAVAILABLE` indica política fail-closed e não inicia coleta. +- Disparos administrativos usam `scraperClient` (via `SCRAPER_URL`) e preservam os códigos operacionais do serviço Go. O código `SCRAPER_ALREADY_RUNNING` é um conflito esperado; `SCRAPER_RUN_LOCK_UNAVAILABLE` indica política fail-closed e não inicia coleta. +- Fila `scraper:keywords:pending` no Valkey (`src/lib/kwsync.ts` no backend, `scraper-go/internal/kwsync`) sincroniza keywords criadas pelo usuário para o scraper-go processar, controlada por `KWSYNC_ENABLED`. ## Banco de dados - Uso de Drizzle ORM com tipos gerados em `src/db/schema`. -- Tabelas: `users`, `credentials`, `keywords`, `saved_jobs`, `user_preferences`, etc. +- Tabelas: `users`, `credentials`, `accounts`, `keywords`, `saved_jobs`, `application_events`, `application_notes`, `user_preferences`, `user_notifications`, `audit_logs`, `permission_rules` (ver detalhes de cada uma em [Database / Schemas](#arquitetura-e-módulos-principais)). - Migrations em `drizzle/`. ## Logs e observabilidade @@ -284,11 +363,15 @@ Definidas/consumidas em `src/config.ts` e outros módulos: - Garantir `SESSION_SECRET` seguro em produção. - Documentar contrato do Valkey (se for serviço externo) e endpoints do Go scraper com exemplos de payload. -- Adicionar exemplos de requests/responses no Swagger para endpoints críticos (auth, jobs/search). +- Adicionar ao Swagger (`backend/src/swagger.ts`) os endpoints de notas de candidatura (`/saved-jobs/:id/notes*`), que ainda não estão documentados ali. +- Corrigir `backend/src/swagger.ts`: o `securitySchemes.cookieAuth` declara o cookie como `candidate_session`, mas o cookie de sessão real é `vagas_session` (`src/lib/session.ts`). ---- +### Corrigido nesta revisão -Para editar ou complementar esta documentação, abra [backend/BACKEND.md](backend/BACKEND.md). +- `toPublicUser` (`src/modules/users/users.mapper.ts`) vazava os campos internos `*Encrypted`/`*Hash` (ciphertext e hashes pesquisáveis) em toda resposta que incluísse um `user` — `/auth/register`, `/auth/login`, `/auth/me`, `/users/profile`, `/admin/users*`. A função agora remove explicitamente esses campos antes de retornar o objeto público. +- O script `scraper`/`scraper:watch` do `backend/package.json` apontava para `index.ts`/`nodemon index.ts`, mas `backend/index.js` (o único entrypoint existente) importava arquivos `.js` inexistentes e uma função `run()` que não existe em `src/app.ts` — ou seja, o comando já estava completamente quebrado e sem nenhum consumidor no repositório (scraping real é feito pelo serviço `scraper-go`). O script, o arquivo `index.js` e a dependência `nodemon` foram removidos. + +--- ## Exemplos de Request / Response @@ -317,12 +400,16 @@ Response (201): "email": "user@example.com", "displayName": "Fulano", "username": "fulano", - "emailVerified": false + "emailVerified": false, + "role": "user", + "isBlocked": false }, - "session": { "userId": "uuid" } + "session": { "userId": "uuid", "role": "user" } } ``` +O `user` retornado é o registro da tabela `users` já sanitizado por `toPublicUser` (sem os campos internos `*Encrypted`/`*Hash`); o exemplo acima mostra só os campos mais relevantes. + - Login (credentials) Request: @@ -340,8 +427,8 @@ Response (200): ```json { - "user": { "id": "uuid", "email": "user@example.com", "username": "fulano" }, - "session": { "userId": "uuid" } + "user": { "id": "uuid", "email": "user@example.com", "username": "fulano", "role": "user" }, + "session": { "userId": "uuid", "role": "user" } } ``` @@ -378,7 +465,7 @@ POST /keywords } ``` -Response (202): +Response (202) — apenas com `KWSYNC_ENABLED=true` (padrão é `false`, e a rota responde `403` nesse caso): ```json { diff --git a/ESCOPO.md b/ESCOPO.md index d0bc7794..b4443a65 100644 --- a/ESCOPO.md +++ b/ESCOPO.md @@ -97,9 +97,10 @@ Permitir que usuários: * Matching avançado * Chat interno * Aplicação automática -* Dashboard analítico * Assinaturas pagas +> Nota: "Dashboard analítico" estava originalmente listado aqui como fora do MVP, mas já foi entregue — o painel administrativo (`front_admin`) tem um dashboard analítico funcional (métricas de usuários, vagas coletadas, status do scraper) desde a introdução do `front_admin`. Ver [BACKEND.md](BACKEND.md) e [front_admin/README.md](front_admin/README.md). + --- # 5. Critérios de Sucesso diff --git a/GUIA-LINEAR-GITHUB.md b/GUIA-LINEAR-GITHUB.md index 198c9712..626371d8 100644 --- a/GUIA-LINEAR-GITHUB.md +++ b/GUIA-LINEAR-GITHUB.md @@ -81,8 +81,8 @@ O fluxo oficial do projeto (alinhado ao [contribuition.md](contribuition.md) e a - **Ao criar esse branch, o card sai de Todo/Backlog e vai para `In Progress` automaticamente** (seção 4). Você não precisa arrastar o card. 4. **Desenvolver e commitar em blocos pequenos.** - - Faça commits coerentes, com o identificador do card na mensagem (ex: `PAV-93 ...`). - - Rode os testes/lint localmente antes de subir (ver [contribuition.md](contribuition.md) e [TESTING.md](TESTING.md)). + - Faça commits coerentes usando Conventional Commits (`tipo: descrição`, ver [contribuition.md](contribuition.md) seção 4), incluindo o identificador do card na mensagem (ex: `docs: cria guia de uso do Linear e integração com GitHub (PAV-93)`). + - Rode os testes/lint localmente antes de subir (ver [contribuition.md](contribuition.md) e [TESTING.md](TESTING.md)). O hook `commit-msg` (Husky + commitlint) valida o formato automaticamente e **não deve ser pulado com `--no-verify`** — o CI revalida as mensagens do PR de qualquer forma. 5. **Abrir o Pull Request.** - Abra o PR do seu fork (`origin`) para o `upstream` na branch **`develop`**. @@ -126,13 +126,13 @@ git checkout -b feature/pav-93-doc-linear-github ### b) Pela mensagem de commit -Coloque o identificador do card na mensagem de commit. Padrão usado no projeto: +Coloque o identificador do card na mensagem de commit, seguindo o padrão Conventional Commits exigido pelo `commit-msg` hook (ver [contribuition.md](contribuition.md) seção 4): ```bash -git commit -m "PAV-93 cria guia de uso do Linear e integração com GitHub" +git commit -m "docs: cria guia de uso do Linear e integração com GitHub (PAV-93)" ``` -Isso deixa o histórico rastreável e reforça o vínculo do trabalho com o card. +Isso deixa o histórico rastreável, reforça o vínculo do trabalho com o card e passa na validação automática do commitlint. ### c) Pelo Pull Request @@ -187,11 +187,11 @@ git checkout -b feature/pav-93-doc-linear-github ➡️ No Linear, o card `PAV-93` muda sozinho de **Todo** para **In Progress**. -**3. Desenvolve e commita** em blocos, com o identificador na mensagem: +**3. Desenvolve e commita** em blocos, com Conventional Commits e o identificador na mensagem: ```bash git add GUIA-LINEAR-GITHUB.md -git commit -m "PAV-93 cria guia de uso do Linear e integração com GitHub" +git commit -m "docs: cria guia de uso do Linear e integração com GitHub (PAV-93)" ``` **4. Sobe o branch e abre o PR** para `develop` (ex.: via `/abrir-pr-jobs-scraper`): diff --git a/GUIA_TESTES_AUTOMATIZADOS.md b/GUIA_TESTES_AUTOMATIZADOS.md index 4ce20f6d..e5667488 100644 --- a/GUIA_TESTES_AUTOMATIZADOS.md +++ b/GUIA_TESTES_AUTOMATIZADOS.md @@ -286,6 +286,12 @@ Comando util para backend: npm run test --workspace=backend ``` +Outros comandos úteis (todos existem em `package.json`/workspace correspondente): `npm run test:coverage --workspace=backend`, `npm run test --workspace=frontend`, `npm run test --workspace=front_admin`, `npm run test:watch --workspace=`. + +### O que o CI realmente valida + +O workflow `.github/workflows/ci.yml` roda, nesta ordem: `test:coverage` no frontend, `test:coverage` no backend, `lint` no frontend e `build` no frontend. Backend e frontend têm meta de cobertura de **80%** (lines/functions/branches/statements) configurada em `backend/vitest.config.js` e `frontend/vitest.config.js` — PRs que reduzirem a cobertura abaixo disso falham o CI. `front_admin` tem os mesmos scripts de teste/lint disponíveis (`front_admin/package.json`), mas **não está incluído** no workflow de CI atual; rode `npm run test --workspace=front_admin` manualmente ao alterar esse workspace. + --- ## Beneficios desse padrao diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md index 01860a79..c08405a2 100644 --- a/LOCAL_DEVELOPMENT.md +++ b/LOCAL_DEVELOPMENT.md @@ -2,40 +2,78 @@ Este guia foi escrito para quem acabou de clonar o repositório e precisa subir o projeto do zero. -Objetivo: permitir execução local com o mínimo de tentativa e erro, usando apenas o que existe hoje no repositório. +**Objetivo:** permitir execução local com o mínimo de tentativa e erro, usando apenas o que existe atualmente no repositório. -## Visão rápida +--- -O monorepo possui 5 blocos principais: +## Visão rápida -- frontend: aplicação principal do usuário final (React + Vite) -- backend: API Node.js/Express (TypeScript + Drizzle) -- front_admin: painel administrativo (React + Vite) -- scraper-go: serviço Go para coleta/agregação de vagas -- observability: stack de métricas e logs (Prometheus/Grafana/Loki etc.) +O monorepo possui os seguintes blocos principais: + +* `frontend` — aplicação principal do usuário final (React + Vite) +* `backend` — API Node.js/Express (TypeScript + Drizzle) +* `front_admin` — painel administrativo (React + Vite) +* `scraper-go` — serviço Go para coleta/agregação de vagas +* `observability` — stack de métricas e logs (Prometheus/Grafana/Loki etc.) + +Além disso, existem arquivos Docker Compose para infraestrutura, migrações, aplicação e observabilidade. + +### Fluxo Docker recomendado + +O fluxo completo de desenvolvimento é: + +```text +PostgreSQL + │ + ▼ +PostgreSQL saudável + │ + ▼ +db:migrate + │ + ▼ +db:seed + │ + ├── Local Developer + ├── Local Admin + ├── vagas de teste + ├── notas + └── eventos + │ + ▼ +security:backfill-user-pii + │ + ▼ +Backend + │ + ├── Frontend + ├── Front Admin + └── Scraper Go +``` -Além disso, há Docker Compose para infraestrutura e execução completa. +Ao utilizar o Docker completo, **migrations e seed são executados automaticamente** antes da inicialização do backend. --- -## 1) Pré-requisitos +# 1) Pré-requisitos -### Obrigatórios +## Obrigatórios 1. Git -2. Node.js 22+ (o backend exige >= 22) -3. npm (projeto usa package-lock e scripts npm) -4. Docker Desktop com Docker Compose (recomendado para subir stack completa) +2. Node.js 22+ +3. npm +4. Docker Desktop +5. Docker Compose -### Opcionais (dependendo do fluxo) +O backend exige Node.js `>= 22`. -1. Go 1.26+ (apenas se você quiser rodar o scraper-go fora do Docker) -2. PostgreSQL local (se quiser rodar backend local sem backend em container) -3. Valkey/Redis local (se quiser rodar backend/scraper local fora de container) +## Opcionais -### Como verificar instalação +* Go 1.26+ — somente se quiser executar `scraper-go` fora do Docker +* PostgreSQL local — caso queira executar o backend fora do Docker +* Valkey/Redis local — caso queira executar backend/scraper fora do Docker -No terminal: +## Verificar instalação ```bash git --version @@ -45,102 +83,109 @@ docker --version docker compose version ``` -Opcional (Go): +Opcional: ```bash go version ``` -### Versão recomendada de gerenciador de pacotes +## Gerenciador de pacotes -- Recomendado pelo projeto: npm -- Observação: existe pnpm-workspace.yaml, mas o lockfile ativo do projeto é package-lock.json. +O projeto utiliza npm e possui `package-lock.json`. ---- +Embora exista `pnpm-workspace.yaml`, o fluxo documentado e recomendado utiliza npm. -## 2) Clonando o projeto +--- -Exemplo: +# 2) Clonando o projeto ```bash git clone https://github.com/Cla-Code-Community/candidate.git cd candidate ``` -Se seu fork/repo tiver outro nome, ajuste os comandos. +Se estiver utilizando um fork, ajuste a URL do repositório. --- -## 3) Estrutura do monorepo - -Estrutura de alto nível relevante: - -- backend/ - - API Express, rotas de auth/users/jobs/keywords/saved-jobs/admin - - migrações em backend/drizzle - - testes unitários e integração em backend/tests -- frontend/ - - app principal (landing, login/cadastro, callback OAuth, dashboard) - - testes em frontend/tests -- front_admin/ - - painel administrativo (dashboard, usuários, scrapers, observabilidade, auditoria, permissões) - - testes em front_admin/tests -- scraper-go/ - - serviço Go de scraping - - endpoints como /scrape, /health, /metrics, /api/keywords -- shared/ - - componentes compartilhados (ex.: CandidateLogo) -- docker/ - - Dockerfile multi-stage para backend/frontend/front_admin -- observability/ - - arquivos de configuração do Prometheus/Grafana/Loki/Alertmanager etc. -- docs/ - - documentação complementar -- docker-compose.infra.yml - - PostgreSQL + Valkey -- docker-compose.yml - - scraper-go + backend + frontend + front_admin -- docker-compose.migrate.yml - - job de migração/backfill antes do backend -- docker-compose.observability.yml - - stack de observabilidade +# 3) Estrutura do monorepo + +Estrutura relevante: + +```text +candidate/ +├── backend/ +│ ├── drizzle/ +│ ├── src/ +│ ├── tests/ +│ └── .env.example +│ +├── frontend/ +│ ├── src/ +│ ├── tests/ +│ └── .env.example +│ +├── front_admin/ +│ ├── src/ +│ └── tests/ +│ +├── scraper-go/ +│ +├── shared/ +│ +├── docker/ +│ +├── observability/ +│ +├── docs/ +│ +├── docker-compose.infra.yml +├── docker-compose.yml +├── docker-compose.migrate.yml +├── docker-compose.observability.yml +├── .env.example +└── package.json +``` --- -## 4) Instalação +# 4) Instalação -Execute na raiz do monorepo: +Na raiz: ```bash npm install ``` -Isso instala dependências da raiz e dos workspaces. +O comando instala as dependências da raiz e dos workspaces. --- -## 5) Variáveis de ambiente - -## Arquivos de ambiente existentes +# 5) Variáveis de ambiente -No estado atual do repositório, existem: +## Arquivos existentes -- .env.example (raiz) -- backend/.env.example -- frontend/.env.example +Atualmente existem: -Também podem existir localmente após setup: +```text +.env.example +backend/.env.example +frontend/.env.example +``` -- .env -- backend/.env +Também podem existir localmente: -Observação importante: +```text +.env +backend/.env +frontend/.env +``` -- front_admin não possui front_admin/.env.example versionado. +O `front_admin` não possui atualmente um `front_admin/.env.example` versionado. -## Como criar +## Criar os arquivos -Na raiz: +Linux/macOS/Git Bash: ```bash cp .env.example .env @@ -148,7 +193,7 @@ cp backend/.env.example backend/.env cp frontend/.env.example frontend/.env ``` -No PowerShell: +PowerShell: ```powershell Copy-Item .env.example .env @@ -156,496 +201,1441 @@ Copy-Item backend/.env.example backend/.env Copy-Item frontend/.env.example frontend/.env ``` -## Variáveis obrigatórias vs opcionais +--- + +# 6) Variáveis importantes -### Obrigatórias na prática para uso completo +## Obrigatórias para o funcionamento completo -1. SESSION_SECRET -2. DATABASE_URL -3. CORS_ALLOWED_ORIGINS -4. FRONTEND_URL -5. GO_SCRAPER_URL (ou SCRAPER_URL em alguns fluxos) +As principais variáveis são: -### Necessárias somente se usar OAuth +```text +SESSION_SECRET +DATABASE_URL +CORS_ALLOWED_ORIGINS +FRONTEND_URL +GO_SCRAPER_URL +``` -1. GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET -2. GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET -3. LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET +Alguns fluxos podem utilizar `SCRAPER_URL` em vez de `GO_SCRAPER_URL`. -### Necessárias somente para fontes externas de scraping +## OAuth -1. ADZUNA_APP_ID / ADZUNA_APP_KEY -2. JOOBLE_API_KEY +Somente necessárias se for utilizar autenticação OAuth: -### Segurança/PII +```text +GOOGLE_CLIENT_ID +GOOGLE_CLIENT_SECRET -1. ENCRYPTION_MASTER_KEY -2. SEARCH_KEY -3. ENCRYPTION_KEY_ID +GITHUB_CLIENT_ID +GITHUB_CLIENT_SECRET -Se esses valores não estiverem definidos adequadamente, recursos que dependem de criptografia e busca segura podem falhar. +LINKEDIN_CLIENT_ID +LINKEDIN_CLIENT_SECRET +``` ---- +## Scraping externo + +Somente necessárias para os respectivos provedores: + +```text +ADZUNA_APP_ID +ADZUNA_APP_KEY + +JOOBLE_API_KEY +``` -## 6) Banco de dados +## Segurança e PII -## O que existe hoje +O backend utiliza: -- ORM: Drizzle -- Dialeto: PostgreSQL -- Migrações: backend/drizzle -- Config Drizzle: backend/drizzle.config.js -- Não há arquivos de seeders versionados no repositório. +```text +ENCRYPTION_MASTER_KEY +SEARCH_KEY +ENCRYPTION_KEY_ID +``` -## Migrações (manual) +### ENCRYPTION_MASTER_KEY -Rodando no workspace backend: +Para desenvolvimento local, gere uma chave de 32 bytes: ```bash -npm run db:migrate --workspace=backend +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +O resultado deve possuir 64 caracteres hexadecimais. + +Exemplo: + +```text +ENCRYPTION_MASTER_KEY=... +``` + +### SEARCH_KEY + +Utilize uma string secreta não vazia, por exemplo: + +```text +SEARCH_KEY=local-dev-search-key ``` -Alternativas: +### SESSION_SECRET + +Utilize uma string longa e aleatória: ```bash -npm run db:generate --workspace=backend -npm run db:push --workspace=backend +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ``` -## Migrações via Docker +--- -No fluxo Docker completo, há o serviço migrate em docker-compose.migrate.yml que roda: +# 7) Banco de dados -- db:migrate -- security:backfill-user-pii +## Tecnologia -## Usuários padrão +* PostgreSQL +* Drizzle ORM +* Migrações em `backend/drizzle` +* Configuração em `backend/drizzle.config.js` -Não há usuários padrão documentados como seed no repositório. +## Tabelas principais -Como criar usuário para testes: +Entre as tabelas utilizadas atualmente estão: -1. Use a tela de cadastro em /register -2. Ou envie POST /auth/register +```text +accounts +application_events +application_notes +audit_logs +credentials +keywords +permission_rules +saved_jobs +user_notifications +user_preferences +users +``` --- -## 7) Docker +# 8) Docker -## 7.1 Subir stack completa (recomendado para onboarding) +## 8.1 Criar a rede Docker -1; Criar rede: +O compose utiliza a rede externa `vagas-net`. + +Crie-a uma única vez: ```bash docker network create vagas-net ``` -2; Subir infra + app + migrate: +Se ela já existir, o Docker informará que a rede já existe. Nesse caso, não é necessário recriá-la. + +--- + +# 9) Subir o ambiente completo + +Este é o fluxo recomendado para quem está começando. + +```bash +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + up --build -d +``` + +O fluxo executará: + +```text +PostgreSQL + ↓ +Valkey + ↓ +migrate + ↓ +db:migrate + ↓ +db:seed + ↓ +security:backfill-user-pii + ↓ +backend + ↓ +frontend + ↓ +front_admin + ↓ +scraper-go +``` + +O backend somente será iniciado depois que o serviço `migrate` terminar com sucesso. + +--- + +# 10) Verificar os containers + +```bash +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + ps +``` + +O serviço `migrate` deve terminar com status de sucesso. + +--- + +# 11) Verificar migrations e seed + +Veja os logs: ```bash -docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml up --build -d +docker logs vagas-migrate +``` + +Você deverá encontrar mensagens relacionadas a: + +```text +migrations applied successfully! +``` + +e ao seed: + +```text +Iniciando seed de desenvolvimento local... + ++ usuário criado: dev@localhost.test (role=user) ++ usuário criado: admin@localhost.test (role=admin) + +Seed concluído. +``` + +Na primeira execução, também serão criadas as vagas, notas e eventos de teste. + +--- + +# 12) Seed de desenvolvimento + +O seed está localizado em: + +```text +backend/src/scripts/seed.ts ``` -## 7.2 Parar stack +O comando npm é: ```bash -docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml down +npm run db:seed ``` -## 7.3 Rebuild +O script é: + +* idempotente +* não destrutivo +* seguro para reexecução +* específico para desenvolvimento local + +Ele não deve ser utilizado para criar dados de produção. + +## O que o seed cria + +O seed cria: + +* 1 usuário comum +* 1 usuário administrador +* 2 registros de preferências +* 3 vagas salvas +* 2 notas privadas +* 2 eventos de alteração de candidatura + +--- + +# 13) Seed automático via Docker + +No fluxo Docker completo, o serviço `migrate` executa: ```bash -docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml up --build -d +npm run db:migrate ``` -## 7.4 Logs +depois: + +```bash +npm run db:seed +``` -Logs de todos os serviços: +e finalmente: ```bash -docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml logs -f +npm run security:backfill-user-pii -- --write ``` -Logs de um serviço específico (exemplo backend): +O comando completo é: ```bash -docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml logs -f backend +sh -c "npm run db:migrate && npm run db:seed && npm run security:backfill-user-pii -- --write" ``` -## 7.5 Observabilidade (opcional) +Portanto: + +> **Não é necessário executar `npm run db:seed` manualmente quando o ambiente foi iniciado pelo Docker completo.** + +--- + +# 14) Seed manual -Subir stack de observabilidade: +Se estiver executando o backend fora do Docker: ```bash -docker compose -f docker-compose.observability.yml up -d +npm run db:seed ``` -Parar: +Ou diretamente no workspace: ```bash -docker compose -f docker-compose.observability.yml down +npm run db:seed --workspace=backend ``` +O seed depende de uma conexão funcional com PostgreSQL e das variáveis de segurança necessárias. + +Se o PostgreSQL estiver apenas dentro do Docker e não estiver exposto ao host, executar o seed diretamente no Windows/Linux/macOS pode resultar em erro de conexão. + +Nesse cenário, prefira o fluxo Docker completo. + --- -## 8) Executando o projeto +# 15) Usuários de desenvolvimento -Você tem 2 caminhos principais. +O seed cria as seguintes contas: -## Caminho A: Docker completo (mais simples para começar) +| Usuário | E-mail | Senha | Role | +| --------------- | ---------------------- | -------------- | ------- | +| Local Developer | `dev@localhost.test` | `Dev@123456` | `user` | +| Local Admin | `admin@localhost.test` | `Admin@123456` | `admin` | -Use o comando da seção de Docker. +**Essas credenciais são exclusivamente para desenvolvimento local.** -Portas esperadas: +Nunca utilize essas senhas em produção. -- frontend: -- front_admin: -- backend: -- scraper-go: +## Local Developer -## Caminho B: Node local (frontend + backend) +Utilize para testar: -Na raiz: +* login +* perfil +* preferências +* busca de vagas +* vagas salvas +* notas +* eventos de candidatura + +## Local Admin + +Utilize para testar funcionalidades administrativas, como: + +```text +/admin/users +/admin/observability/metrics +/admin +``` + +Funcionalidades que exigem `super_admin` não são concedidas automaticamente pelo seed. + +--- + +# 16) Verificar usuários diretamente no PostgreSQL + +Para verificar se o seed foi executado: ```bash -npm run dev +docker exec vagas-postgres \ + psql -U vagas -d vagas \ + -c "SELECT email_hash, role FROM users;" +``` + +Para consultar credenciais: + +```bash +docker exec vagas-postgres \ + psql -U vagas -d vagas \ + -c "SELECT email_hash FROM credentials;" ``` -Isso sobe: +Não espere encontrar o e-mail em texto puro nas colunas protegidas. + +O projeto utiliza mecanismos de proteção/normalização para os dados de identificação. -- frontend em 5173 -- backend em 3001 +--- + +# 17) Migrações manuais -Para incluir admin junto: +Para executar somente as migrations: ```bash -npm run dev:admin +npm run db:migrate --workspace=backend ``` -Comandos separados: +Gerar migration: ```bash -npm run dev:frontend -npm run dev:backend -npm run dev:front_admin +npm run db:generate --workspace=backend ``` -Observação importante para o Caminho B: +Aplicar schema diretamente: -- Se backend estiver fora de container, DATABASE_URL e VALKEY_URL precisam apontar para serviços acessíveis pelo host. -- No compose de infra atual, PostgreSQL e Valkey não estão expostos por portas no host por padrão. -- Portanto, para backend local funcionar, você precisa: - 1. usar banco/valkey locais no host, ou - 2. expor portas no compose (ajuste manual), ou - 3. rodar backend também em container. +```bash +npm run db:push --workspace=backend +``` + +Em desenvolvimento normal, prefira migrations versionadas. --- -## 9) Acessando a aplicação +# 18) Autenticação -URLs principais: +A API não utiliza JWT para a sessão principal. -- App principal: -- Login: -- Cadastro: -- Dashboard app: /home, /dashboard, /vagas, /mentoria, /perfil, /ajuda -- Callback OAuth: /auth/callback +A autenticação utiliza: -Backend: +```text +iron-session +``` + +com sessão baseada em cookie. + +O cookie utilizado é: -- Health: -- Swagger: -- Metrics: +```text +vagas_session +``` + +Após o login, o cliente deve manter esse cookie para acessar endpoints autenticados. + +--- -Scraper: +# 19) Login pela API -- Health: -- Metrics: -- Admin jobs count: +Endpoint: -Front admin: +```text +POST /auth/login +``` + +Body: -- -- rota de login: /login -- rotas principais: /dashboard, /users, /scrapers, /observability, /audit, /permissions, /settings +```json +{ + "email": "dev@localhost.test", + "password": "Dev@123456" +} +``` -## Login/senha padrão +## Usando curl -Não há credenciais padrão versionadas/documentadas para produção/local no repositório. +```bash +curl -i -c cookies.txt \ + -X POST http://localhost:3001/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"dev@localhost.test","password":"Dev@123456"}' +``` -Fluxo recomendado para ambiente local: +Uma resposta bem-sucedida deve retornar: -1. criar usuário via cadastro na aplicação principal -2. usar login com email/senha criados +```text +HTTP 200 +``` -Para OAuth, é necessário configurar credenciais de provedores no .env. +e um header `Set-Cookie` contendo a sessão. --- -## 10) Fluxo da aplicação (visão funcional) +# 20) Testar sessão autenticada + +Depois do login: + +```bash +curl -i \ + -b cookies.txt \ + http://localhost:3001/auth/me +``` -## Aplicação principal (frontend) +--- -1. Landing page pública em / -2. Cadastro em /register -3. Login em /login -4. Callback OAuth em /auth/callback -5. Após autenticação, acesso a rotas protegidas: - - /home - - /dashboard - - /vagas - - /mentoria - - /perfil - - /ajuda +# 21) Listar vagas salvas -## Backend +```bash +curl -i \ + -b cookies.txt \ + http://localhost:3001/saved-jobs +``` -- Sessão via cookie (iron-session) -- Rotas protegidas para usuários autenticados: - - /users - - /jobs - - /keywords - - /notifications - - /saved-jobs - - /admin +O usuário `dev@localhost.test` deverá possuir as 3 vagas criadas pelo seed. -## Scraper e fila +--- -- Backend pode enfileirar keywords no Valkey (chave scraper:keywords:pending) -- Scraper-go processa keywords e agrega vagas +# 22) Criar uma vaga salva -## Front admin +```bash +curl -i \ + -b cookies.txt \ + -X POST http://localhost:3001/saved-jobs \ + -H "Content-Type: application/json" \ + -d '{ + "jobLink":"https://example.com/jobs/teste-manual", + "jobTitle":"Vaga de teste", + "notes":"Criada manualmente via curl" + }' +``` -- Login próprio do painel -- Controle de acesso por papel (support/admin/super_admin) -- Seções administrativas para operação da plataforma +Guarde o `id` retornado. --- -## 11) Testando manualmente (roteiro prático) +# 23) Atualizar uma vaga salva -Abaixo, os testes manuais sugeridos para os módulos principais. +Substitua `SAVED_JOB_ID` pelo ID retornado: -## 11.1 Login +```bash +curl -i \ + -b cookies.txt \ + -X PATCH http://localhost:3001/saved-jobs/SAVED_JOB_ID \ + -H "Content-Type: application/json" \ + -d '{ + "status":"applied", + "notes":"Nota atualizada via curl" + }' +``` -Passos: +--- -1. Acesse -2. Tente enviar vazio -3. Informe credenciais inválidas -4. Informe credenciais válidas +# 24) Notas privadas -Resultado esperado: +Criar nota: -- validações de campo aparecem -- credenciais inválidas não autenticam -- credenciais válidas redirecionam para área protegida +```bash +curl -i \ + -b cookies.txt \ + -X POST http://localhost:3001/saved-jobs/SAVED_JOB_ID/notes \ + -H "Content-Type: application/json" \ + -d '{ + "content":"Recrutador confirmou entrevista técnica." + }' +``` -## 11.2 Cadastro +Listar notas: -Passos: +```bash +curl -i \ + -b cookies.txt \ + http://localhost:3001/saved-jobs/SAVED_JOB_ID/notes +``` -1. Acesse -2. Preencha campos obrigatórios -3. Teste telefone opcional vazio -4. Teste telefone válido -5. Teste telefone inválido +--- -Resultado esperado: +# 25) Excluir vaga salva -- cadastro válido cria conta e redireciona para login -- telefone vazio é permitido -- telefone inválido exibe erro e bloqueia envio +```bash +curl -i \ + -b cookies.txt \ + -X DELETE \ + http://localhost:3001/saved-jobs/SAVED_JOB_ID +``` -## 11.3 Busca de vagas +Resposta esperada: -Passos: +```text +204 No Content +``` -1. Faça login -2. Vá para /vagas -3. Acione busca/filtros +--- -Resultado esperado: +# 26) Testar usuário administrador -- requests de busca retornam sem quebrar a UI -- estados de loading/erro são exibidos corretamente +Crie um cookie jar separado: -## 11.4 Vagas salvas +```bash +curl -i -c admin-cookies.txt \ + -X POST http://localhost:3001/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@localhost.test","password":"Admin@123456"}' +``` -Passos: +Depois: -1. Em /vagas, salve uma vaga -2. Abra lista de salvas -3. Edite status/notas se disponível -4. Remova vaga salva +```bash +curl -i \ + -b admin-cookies.txt \ + http://localhost:3001/admin/users +``` -Resultado esperado: +--- -- operações de criar/editar/remover refletem na interface +# 27) Logout -## 11.5 Perfil e preferências +```bash +curl -i \ + -b cookies.txt \ + -X POST \ + http://localhost:3001/auth/logout +``` -Passos: +--- -1. Vá para /perfil -2. Atualize dados do perfil -3. Atualize preferências +# 28) Prefixo da API -Resultado esperado: +A API possui o prefixo oficial: -- alterações persistem -- recarregar a tela mantém dados +```text +/api/v1 +``` -## 11.6 Painel administrativo +Exemplo: -Passos: +```text +http://localhost:3001/api/v1/auth/login +``` -1. Acesse -2. Faça login com conta com permissão -3. Navegue por dashboard/users/scrapers/observability/audit/permissions/settings +Algumas rotas sem `/api/v1` continuam disponíveis por compatibilidade. -Resultado esperado: +Para novas integrações, prefira o prefixo: -- acesso a páginas conforme papel -- usuário sem papel mínimo deve cair em 403 +```text +/api/v1 +``` --- -## 12) Como reproduzir bugs corretamente +# 29) Swagger/OpenAPI + +O Swagger está disponível em: + +```text +http://localhost:3001/docs +``` -Use sempre este formato: +Acesse no navegador: -1. Contexto - - branch - - commit - - ambiente (Docker ou local) - - variáveis relevantes -2. Passos para reproduzir - - sequenciais e exatos -3. Resultado atual -4. Resultado esperado -5. Evidências - - print, log, request/response, stack trace +```text +http://localhost:3001/docs +``` -Modelo: +Use o Swagger para consultar: -- Passos: - 1. ... - 2. ... - 3. ... -- Resultado atual: ... -- Resultado esperado: ... +* métodos HTTP +* parâmetros +* schemas +* respostas +* autenticação +* endpoints disponíveis -Exemplo real (telefone): +Para endpoints que ainda não possuem documentação completa no Swagger, utilize os exemplos deste documento, `BACKEND.md` ou a coleção Bruno em: -- Passos: - 1. abrir /register - 2. inserir telefone muito longo - 3. tentar enviar -- Resultado atual (bug): campo aceitava valor inválido -- Resultado esperado: bloquear dígitos excedentes e rejeitar telefone inválido +```text +backend/bruno/ +``` --- -## 13) Testes automatizados +# 30) URLs principais -## 13.1 Monorepo (cobertura consolidada) +## Frontend -Na raiz: +```text +http://localhost:5173 +``` -```bash -npm run test:coverage +Login: + +```text +http://localhost:5173/login ``` -## 13.2 Backend +Cadastro: -```bash -npm run test --workspace=backend -npm run test:coverage --workspace=backend -npm run test:watch --workspace=backend +```text +http://localhost:5173/register ``` -## 13.3 Frontend +## Backend -```bash -npm run test --workspace=frontend -npm run test:coverage --workspace=frontend -npm run test:watch --workspace=frontend +Health: + +```text +http://localhost:3001/health ``` -## 13.4 Front admin +Swagger: -```bash -npm run test --workspace=front_admin -npm run test:coverage --workspace=front_admin +```text +http://localhost:3001/docs ``` -## 13.5 Testes de integração e E2E +Metrics: + +```text +http://localhost:3001/metrics +``` -- Integração: existe no backend (backend/tests/integration). -- E2E browser (Playwright/Cypress): não há suíte E2E ativa/versionada no estado atual do repositório. +## Scraper ---- +Health: + +```text +http://localhost:8081/health +``` + +Metrics: + +```text +http://localhost:8081/metrics +``` + +Admin jobs count: + +```text +http://localhost:8081/admin/jobs/count +``` -## 14) Checklist antes de abrir Pull Request +## Front admin + +```text +http://localhost:5174 +``` -Use esta checklist: +Login: -- [ ] Projeto instala do zero (npm install) -- [ ] App sobe localmente (npm run dev) ou Docker completo -- [ ] Backend responde /health -- [ ] Frontend abre sem erro crítico -- [ ] Testes do escopo alterado passando -- [ ] Cobertura mantida para o escopo afetado -- [ ] Lint sem erros no frontend/front_admin -- [ ] Sem erro de TypeScript no escopo alterado -- [ ] Funcionalidade validada manualmente -- [ ] Sem regressões observáveis -- [ ] Logs limpos (sem erro não tratado) +```text +http://localhost:5174/login +``` --- -## Comandos úteis extras +# 31) Executar projeto sem Docker completo -Builds: +Existem duas possibilidades. + +## Caminho A — Docker completo + +Recomendado: ```bash -npm run build:frontend -npm run build:front_admin +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + up --build -d ``` -Validação rápida da raiz: +## Caminho B — Node local ```bash -npm run validate +npm run dev ``` -Electron: +Isso inicia: + +```text +frontend → 5173 +backend → 3001 +``` + +Para incluir o painel administrativo: ```bash -npm run electron -npm run electron:dev +npm run dev:admin +``` + +Ou individualmente: + +```bash +npm run dev:frontend +npm run dev:backend +npm run dev:front_admin ``` --- -## Referências do projeto +# 32) Atenção ao executar backend fora do Docker + +Se o backend estiver rodando diretamente no host: + +```text +npm run dev:backend +``` + +o `DATABASE_URL` precisa apontar para um PostgreSQL acessível pelo host. + +O compose de infraestrutura atual não expõe necessariamente PostgreSQL e Valkey para o host. + +Portanto, existem três alternativas: + +1. executar PostgreSQL/Valkey localmente; +2. expor as portas dos containers; +3. executar o backend dentro do Docker. -- README.md (visão geral) -- BACKEND.md (detalhes da API) -- SCRAPER.md (detalhes do scraper-go) -- TESTING.md (roteiro de QA) -- frontend/README.md -- front_admin/README.md +Para onboarding, a terceira opção é a mais simples. --- -## Lacunas identificadas no estado atual (sem suposição) +# 33) Parar o ambiente + +```bash +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + down +``` + +## Importante -1. Não há front_admin/.env.example versionado. -2. Não há seed oficial versionado para usuários/dados iniciais. -3. Não há credenciais padrão oficiais documentadas para login local. -4. Não há suíte E2E browser ativa/versionada. -5. Há documentação antiga em alguns pontos com prefixo /api que pode divergir das rotas montadas em runtime (que usam /auth, /users, /jobs, etc.). +Não utilize: -Se você for manter este guia, priorize resolver essas lacunas para reduzir tempo de onboarding. +```bash +docker compose down -v +``` + +sem entender as consequências. + +A opção `-v` pode remover volumes e, consequentemente, apagar o banco de desenvolvimento persistido. + +--- + +# 34) Rebuild completo + +```bash +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + up --build -d +``` + +--- + +# 35) Logs + +Todos os serviços: + +```bash +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + logs -f +``` + +Somente backend: + +```bash +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + logs -f backend +``` + +Somente migrate: + +```bash +docker logs -f vagas-migrate +``` + +--- + +# 36) Observabilidade + +Para subir a stack: + +```bash +docker compose \ + -f docker-compose.observability.yml \ + up -d +``` + +Para parar: + +```bash +docker compose \ + -f docker-compose.observability.yml \ + down +``` + +--- + +# 37) Testes manuais + +## Login + +1. Acesse: + +```text +http://localhost:5173/login +``` + +2. Teste credenciais inválidas. +3. Teste: + +```text +dev@localhost.test +Dev@123456 +``` + +4. Verifique o redirecionamento para área autenticada. + +## Cadastro + +1. Acesse: + +```text +http://localhost:5173/register +``` + +2. Teste campos obrigatórios. +3. Teste telefone vazio. +4. Teste telefone válido. +5. Teste telefone inválido. + +## Busca de vagas + +1. Faça login. +2. Acesse `/vagas`. +3. Execute uma busca. +4. Teste filtros. +5. Verifique loading e tratamento de erro. + +## Vagas salvas + +1. Salve uma vaga. +2. Abra as vagas salvas. +3. Altere o status. +4. Adicione uma nota. +5. Remova a vaga. + +## Perfil + +1. Acesse `/perfil`. +2. Altere dados. +3. Altere preferências. +4. Recarregue a página. +5. Confirme persistência. + +## Painel administrativo + +Acesse: + +```text +http://localhost:5174/login +``` + +Use: + +```text +admin@localhost.test +Admin@123456 +``` + +Teste: + +```text +/dashboard +/users +/scrapers +/observability +/audit +/permissions +/settings +``` + +--- + +# 38) Testes automatizados + +## Monorepo + +```bash +npm run test:coverage +``` + +## Backend + +```bash +npm run test --workspace=backend +``` + +```bash +npm run test:coverage --workspace=backend +``` + +```bash +npm run test:watch --workspace=backend +``` + +## Frontend + +```bash +npm run test --workspace=frontend +``` + +```bash +npm run test:coverage --workspace=frontend +``` + +```bash +npm run test:watch --workspace=frontend +``` + +## Front admin + +```bash +npm run test --workspace=front_admin +``` + +```bash +npm run test:coverage --workspace=front_admin +``` + +## Integração + +Os testes de integração existentes ficam em: + +```text +backend/tests/integration +``` + +Não há atualmente uma suíte E2E browser ativa/versionada. + +--- + +# 39) Comandos úteis + +Build frontend: + +```bash +npm run build:frontend +``` + +Build front admin: + +```bash +npm run build:front_admin +``` + +Validação: + +```bash +npm run validate +``` + +Electron: + +```bash +npm run electron +``` + +```bash +npm run electron:dev +``` + +Seed: + +```bash +npm run db:seed +``` + +Migrations: + +```bash +npm run db:migrate --workspace=backend +``` + +--- + +# 40) Como reproduzir bugs + +Ao abrir uma issue ou Pull Request, informe: + +## Contexto + +* branch +* commit +* ambiente +* Docker ou execução local +* variáveis relevantes + +## Passos + +Liste os passos exatamente na ordem em que foram executados. + +## Resultado atual + +Descreva o comportamento observado. + +## Resultado esperado + +Descreva o comportamento correto. + +## Evidências + +Inclua quando possível: + +* screenshot +* logs +* request +* response +* stack trace +* status HTTP + +### Modelo + +```text +Contexto: +- branch: ... +- commit: ... +- ambiente: Docker + +Passos: +1. ... +2. ... +3. ... + +Resultado atual: +... + +Resultado esperado: +... + +Evidências: +... +``` + +--- + +# 41) Checklist antes de abrir Pull Request + +* [ ] `npm install` executa sem erro +* [ ] Docker sobe corretamente +* [ ] migrations executam +* [ ] seed executa +* [ ] backend responde `/health` +* [ ] frontend abre +* [ ] login funciona +* [ ] funcionalidades alteradas foram testadas +* [ ] testes automatizados passam +* [ ] cobertura foi mantida no escopo alterado +* [ ] TypeScript não apresenta erros +* [ ] lint passa +* [ ] não existem erros não tratados nos logs +* [ ] não existem regressões observáveis +* [ ] documentação foi atualizada quando necessário + +--- + +# 42) Fluxo recomendado para um novo desenvolvedor + +Para a maioria dos casos, basta seguir: + +```bash +git clone https://github.com/Cla-Code-Community/candidate.git +cd candidate +``` + +Depois: + +```bash +npm install +``` + +Criar os ambientes: + +```bash +cp .env.example .env +cp backend/.env.example backend/.env +cp frontend/.env.example frontend/.env +``` + +Criar a rede: + +```bash +docker network create vagas-net +``` + +Subir tudo: + +```bash +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + up --build -d +``` + +Verificar: + +```bash +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + ps +``` + +Verificar o migrate: + +```bash +docker logs vagas-migrate +``` + +Abrir: + +```text +Frontend: +http://localhost:5173 + +Admin: +http://localhost:5174 + +Backend: +http://localhost:3001 + +Swagger: +http://localhost:3001/docs +``` + +Login comum: + +```text +E-mail: dev@localhost.test +Senha: Dev@123456 +``` + +Login administrativo: + +```text +E-mail: admin@localhost.test +Senha: Admin@123456 +``` + +--- + +# 43) Reexecução do seed + +O seed pode ser executado novamente: + +```bash +npm run db:seed +``` + +Ele é idempotente. + +Isso significa que executar novamente: + +```text +1ª execução → cria dados +2ª execução → mantém dados existentes +3ª execução → mantém dados existentes +``` + +O seed não deve apagar nem sobrescrever dados existentes. + +No Docker completo, o seed também é executado automaticamente pelo serviço `migrate`. + +--- + +# 44) Diagnóstico rápido + +## Login retorna `401 Credenciais inválidas` + +Primeiro verifique se o seed foi executado: + +```bash +docker logs vagas-migrate +``` + +Depois: + +```bash +docker exec vagas-postgres \ + psql -U vagas -d vagas \ + -c "SELECT role FROM users;" +``` + +Se os usuários não existirem, o seed não foi executado corretamente. + +## `ECONNREFUSED` ao executar `npm run db:seed` + +Isso normalmente significa que o PostgreSQL definido em `DATABASE_URL` não está acessível pelo host. + +Se o banco estiver dentro do Docker, prefira subir o ambiente completo: + +```bash +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + up --build -d +``` + +O seed será executado dentro do ambiente Docker, utilizando a conexão interna: + +```text +postgres:5432 +``` + +## `network vagas-net declared as external, but could not be found` + +Crie a rede: + +```bash +docker network create vagas-net +``` + +Depois suba novamente os serviços. + +## Backend não inicia + +Verifique: + +```bash +docker logs vagas-migrate +``` + +e: + +```bash +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + logs backend +``` + +O backend depende do término bem-sucedido do serviço `migrate`. + +--- + +# 45) Referências do projeto + +Documentação complementar: + +```text +README.md +BACKEND.md +SCRAPER.md +TESTING.md +frontend/README.md +front_admin/README.md +``` + +Código do seed: + +```text +backend/src/scripts/seed.ts +``` + +Migrações: + +```text +backend/drizzle/ +``` + +Configuração Docker: + +```text +docker-compose.infra.yml +docker-compose.yml +docker-compose.migrate.yml +docker-compose.observability.yml +``` + +--- + +# 46) Limitações conhecidas + +No estado atual do repositório: + +1. `front_admin/.env.example` não está versionado. +2. Não existe uma suíte E2E browser ativa/versionada. +3. Existem pontos de documentação antiga utilizando `/api` que podem divergir das rotas montadas em runtime. +4. Nem todos os endpoints possuem documentação Swagger completa. +5. O fluxo de desenvolvimento local recomendado é o Docker completo, pois ele garante a disponibilidade do PostgreSQL, Valkey, migrations, seed e backend no mesmo ambiente. + +--- + +# 47) Resumo dos comandos essenciais + +### Primeiro setup + +```bash +npm install + +cp .env.example .env +cp backend/.env.example backend/.env +cp frontend/.env.example frontend/.env + +docker network create vagas-net + +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + up --build -d +``` + +### Verificar + +```bash +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + ps +``` + +```bash +docker logs vagas-migrate +``` + +### Aplicação + +```text +Frontend: http://localhost:5173 +Admin: http://localhost:5174 +Backend: http://localhost:3001 +Swagger: http://localhost:3001/docs +``` + +### Credenciais locais + +```text +Developer +dev@localhost.test +Dev@123456 + +Admin +admin@localhost.test +Admin@123456 +``` + +### Parar + +```bash +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + down +``` + +### Subir novamente + +```bash +docker compose \ + -f docker-compose.infra.yml \ + -f docker-compose.yml \ + -f docker-compose.migrate.yml \ + up --build -d +``` diff --git a/OBSERVIBILITY.MD b/OBSERVIBILITY.MD index e28076c5..9447d722 100644 --- a/OBSERVIBILITY.MD +++ b/OBSERVIBILITY.MD @@ -4,10 +4,10 @@ Este documento descreve a observabilidade do projeto Jobs Scraper Global: métri ## Visão Geral -- Stack principal (primeira escolha): Prometheus (métricas) + Grafana (dashboards) + Loki (logs) + Promtail (ingestão de logs). -- Stack alternativo presente no repositório: ELK (Elasticsearch + Filebeat + Kibana) — mantido como opção futura. +- Stack real e operacional (o único que sobe hoje via Docker Compose): Prometheus (métricas) + Grafana (dashboards) + Loki (logs) + Promtail (ingestão de logs) + exporters (Postgres/Redis/node) + cAdvisor + Alertmanager — tudo definido em `docker-compose.observability.yml`. +- Configs órfãs no repositório (ELK: Elasticsearch + Filebeat + Kibana): existem apenas arquivos de configuração soltos em `observability/elasticsearch`, `observability/kibana` e `observability/filebeat`. **Nenhum desses serviços está declarado em `docker-compose.observability.yml` nem em qualquer outro `docker-compose*.yml`** — não há como "ativar" ELK apenas rodando um comando; seria necessário escrever os serviços do zero no Compose. -> Nota operacional: ELK está presente no repositório, porém a operação atual privilegia Loki/Promtail por exigências de recursos (memória/IO e manutenção). Se houver capacidade de infra suficiente no ambiente de destino, ELK pode ser ativado conforme instruções abaixo. +> Nota operacional: os arquivos de config de ELK ficam no repositório apenas como referência/planejamento futuro. A stack que roda de fato hoje é Prometheus/Grafana/Loki/Promtail, descrita abaixo. ## Arquitetura e artefatos no repositório @@ -17,7 +17,7 @@ Este documento descreve a observabilidade do projeto Jobs Scraper Global: métri - Grafana provisioning (datasources & dashboards): [observability/grafana/provisioning](observability/grafana/provisioning) - Loki: [observability/loki/loki-config.yml](observability/loki/loki-config.yml) - Promtail: [observability/promtail/promtail.yml](observability/promtail/promtail.yml) (configuração versionada no repositório) - - ELK: + - ELK (não wireado a nenhum docker-compose — apenas arquivos de config): - Elasticsearch: [observability/elasticsearch/elasticsearch.yml](observability/elasticsearch/elasticsearch.yml) - Kibana: [observability/kibana/kibana.yml](observability/kibana/kibana.yml) - Filebeat: [observability/filebeat/filebeat.yml](observability/filebeat/filebeat.yml) @@ -92,7 +92,7 @@ Para usar Promtail com o compose já presente: 1. O arquivo `observability/promtail/promtail.yml` já está versionado no repositório. 2. O serviço `promtail` deve montar o arquivo em `/etc/promtail/promtail.yml` e usar o volume nomeado apenas para estado em `/etc/promtail/state`. -Alternativa: continuar com Filebeat → Elasticsearch. O `filebeat.yml` atual usa autodiscover e envia diretamente para `elasticsearch:9200`. +Alternativa não operacional hoje: Filebeat → Elasticsearch. O `filebeat.yml` está configurado com autodiscover e aponta para `elasticsearch:9200`, mas nenhum dos dois serviços (`filebeat`, `elasticsearch`) é declarado em `docker-compose.observability.yml` — para usar essa alternativa é preciso adicionar os serviços ao compose antes. ## Dashboards (Grafana) @@ -130,7 +130,7 @@ docker compose -f docker-compose.observability.yml up -d - Prometheus UI: (ou 9090 se mapear 9090:9090) - Grafana: - Loki: -- Kibana (se optar por ELK): +- Kibana: não aplicável hoje — ELK não está declarado em nenhum `docker-compose*.yml` do repositório (ver seção "Visão Geral"). ## Escalabilidade, retenção e custos @@ -151,7 +151,7 @@ docker compose -f docker-compose.observability.yml up -d - Se logs não aparecerem no Grafana/Loki: - Confirme que o `promtail` está em execução, com configuração apontando para `/var/lib/docker/containers/*/*.log` e `url: http://loki:3100`. - - Como alternativa, verifique `filebeat` se usando ELK. + - `filebeat`/ELK não é uma alternativa operacional hoje (não está no compose) — ver "Visão Geral" acima. ## Passos recomendados próximos diff --git a/README.md b/README.md index 1b811312..f2013551 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,10 @@ [SCRAPER](SCRAPER.md) | [BACKEND](BACKEND.md) | [TESTING](TESTING.md) | +[OBSERVABILITY](OBSERVIBILITY.MD) | [CONTRIBUTING](contribuition.md) | [ESCOPO](ESCOPO.md) | +[SECURITY](SECURITY.md) | [Frontend](frontend/README.md) | [Frontend Architecture](frontend/ARCHITECTURE.md) | [Front Admin](front_admin/README.md) @@ -30,6 +32,7 @@ O produto evoluiu para um modelo orientado a serviços (API + scraper Go + cache - Documentação backend detalhada: [BACKEND.md](BACKEND.md) - Documentação scraper Go: [SCRAPER.md](SCRAPER.md) - Guia de testes: [TESTING.md](TESTING.md) +- Documentação de observabilidade: [OBSERVIBILITY.MD](OBSERVIBILITY.MD) - Documentação inicial do MVP (Visão PO) [ESCOPO.md](ESCOPO.md) ## Sumário @@ -73,6 +76,7 @@ Objetivo de produto: fornecer uma base robusta para busca, filtragem e gestão d ├─ docker-compose.yml # App stack (frontend + front_admin + backend + scraper-go) ├─ docker-compose.infra.yml # Infra stack (Postgres + Valkey) ├─ docker-compose.migrate.yml # Migration job do backend +├─ docker-compose.observability.yml # Stack de observabilidade (Prometheus/Grafana/Loki) └─ .github/workflows/ci.yml # CI ``` @@ -208,8 +212,6 @@ Os comandos abaixo existem hoje no repositório e foram conferidos nos `package. - npm run dev:frontend - npm run dev:backend - npm run dev:front_admin -- npm run scraper -- npm run scraper:watch - npm run test - npm run test:coverage - npm run build @@ -222,6 +224,7 @@ Os comandos abaixo existem hoje no repositório e foram conferidos nos `package. - npm run db:generate - npm run db:migrate - npm run db:push +- npm run db:seed ### Backend @@ -235,6 +238,9 @@ Os comandos abaixo existem hoje no repositório e foram conferidos nos `package. - npm run db:generate - npm run db:migrate - npm run db:push +- npm run db:seed +- npm run security:backfill-user-pii +- npm run clear-cache ### Frontend @@ -257,18 +263,20 @@ Os comandos abaixo existem hoje no repositório e foram conferidos nos `package. ## API backend (estado atual) -Base: / +Base: `/api/v1` (ver seção [Versionamento da API](#versionamento-da-api) mais abaixo; as mesmas rotas sem prefixo continuam funcionando como compatibilidade temporária). Sistema: -- GET /health +- GET /health (também em `/api/v1/health`) Autenticação: - GET /auth/:provider/url - GET /auth/:provider/callback +- GET /auth/connections +- DELETE /auth/connections/:provider - POST /auth/register -- POST /auth/login +- POST /auth/login (rate limit por IP e por conta) - POST /auth/logout - GET /auth/me @@ -294,11 +302,32 @@ Saved jobs: - GET /saved-jobs - GET /saved-jobs/:id +- GET /saved-jobs/:id/events +- GET /saved-jobs/:id/notes +- POST /saved-jobs/:id/notes +- PATCH /saved-jobs/:id/notes/:noteId +- DELETE /saved-jobs/:id/notes/:noteId - POST /saved-jobs - PATCH /saved-jobs/:id - DELETE /saved-jobs/:id -Admin: +Notificações: + +- GET /notifications +- PATCH /notifications/read-all +- PATCH /notifications/:id/read +- DELETE /notifications + +Admin (role mínima `support`): + +- GET /admin/dashboard +- GET /admin/scrapers +- GET /admin/scrapers/status +- GET /admin/scrapers/jobs +- GET /admin/scrapers/jobs/count +- GET /admin/observability/health + +Admin (role mínima `admin`): - GET /admin/users - GET /admin/users/:id @@ -306,15 +335,42 @@ Admin: - PATCH /admin/users/:id/unblock - POST /admin/users/:id/reset - POST /admin/scrapers/run +- POST /admin/scrapers/:id/run - GET /admin/observability/metrics - GET /admin/observability/dashboards - GET /admin/audit - GET /admin/permissions/rules +Admin (role mínima `super_admin`): + +- PATCH /admin/users/:id/role +- DELETE /admin/users/:id +- PATCH /admin/permissions/rules +- DELETE /admin/jobs/cache + Swagger: - GET /docs +### Versionamento da API + +Os endpoints públicos usam o prefixo `/api/v1` (por exemplo, +`GET /api/v1/jobs/search`). A interface Swagger está disponível em `GET /docs` +e documenta essa versão — exceto os endpoints de notas de candidatura +(`/saved-jobs/:id/notes*`, ver seção "Saved jobs"), que ainda não têm entrada +em `backend/src/swagger.ts`. As rotas sem prefixo permanecem temporariamente +por compatibilidade com clientes existentes. + +> Nota: `backend/src/swagger.ts` declara o cookie de sessão do Swagger como +> `candidate_session`, mas o cookie real emitido pela API é `vagas_session` +> (`backend/src/lib/session.ts`) — divergência a corrigir no código, não +> uma instrução para testar com o nome errado. + +Para atualizar a documentação, altere os schemas e rotas em +`backend/src/swagger.ts` ou as anotações `@swagger` das rotas e reinicie o +backend. Em desenvolvimento, acesse a interface em +`http://localhost:3001/docs`. + ## Docker (infra + aplicação) Este projeto separa infraestrutura e aplicação em dois arquivos Compose: diff --git a/SCRAPER.md b/SCRAPER.md index 740ba70e..1497b797 100644 --- a/SCRAPER.md +++ b/SCRAPER.md @@ -360,6 +360,12 @@ Boas práticas nos adaptadores: - Filtros estruturados de localização, modelo, contrato e senioridade continuam em chaves como `scraper:jobs:country:`, `scraper:jobs:model:` e `scraper:jobs:contract:`. - `jobstore.StableID` garante IDs determinísticos para permitir identificação e deduplicação entre execuções. +## Sincronização de keywords com o backend (kwsync) + +- O backend publica keywords criadas por usuários na lista Valkey `scraper:keywords:pending` (`backend/src/lib/kwsync.ts`, via `lPush`) quando `POST /keywords` é chamado. +- `internal/kwsync` (`scraper-go/internal/kwsync/kwsync.go`) implementa um `Consumer` que faz polling dessa mesma chave a cada 30s e persiste as keywords novas no armazenamento local de keywords do scraper. +- Controlado pela variável `KWSYNC_ENABLED` (padrão `false`) em ambos os lados — quando desabilitado, o backend recusa `POST /keywords` com `403` e o consumidor Go não roda. + ## Cache e configuração - Cache é abstraído por `internal/cache` com implementações Redis (`NewRedisCache`) e memória (fallback para testes). @@ -391,6 +397,7 @@ Confirme no serviço `scraper-go` os equivalentes de `SCRAPER_MAX_CONCURRENCY=12 - `SCRAPER_RUN_LOCK_RENEW_INTERVAL` — intervalo de renovação. Padrão: `30s`; deve ser menor que `SCRAPER_RUN_LOCK_TTL`. Variável ausente usa o default; valor explícito vazio ou inválido impede a inicialização. No Compose, usa `${SCRAPER_RUN_LOCK_RENEW_INTERVAL-30s}`. - `GOMAXPROCS` — limite efetivo de threads executando código Go simultaneamente. Valor inicial no Compose: `2`. - `GOMEMLIMIT` — meta de memória do runtime/GC. Valor inicial no Compose: `1500MiB`; não substitui `mem_limit` do container. +- `KWSYNC_ENABLED` — padrão `false`. Liga/desliga o consumidor da fila `scraper:keywords:pending` (ver seção "Sincronização de keywords com o backend"). - `JOOBLE_API_KEY` — Jooble integration. - `ADZUNA_APP_ID` / `ADZUNA_APP_KEY` — Adzuna API. - `LINKEDIN_KEYWORD_SLOT_SIZE` — quantidade máxima de keywords do LinkedIn por execução quando a busca vier com uma lista grande. Padrão: `30`. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..7f8aa0ae --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Segurança + +## Content Security Policy + +Os frontends publicados por Nginx e Vercel enviam uma Content Security Policy (CSP) restritiva. A política bloqueia plugins, enquadramento por outros sites, scripts externos e execução de scripts inline. Estilos inline permanecem permitidos porque componentes React aplicam estilos dinâmicos; isso não autoriza JavaScript inline. + +As origens permitidas são mantidas explicitamente nos arquivos `frontend/nginx.conf`, `front_admin/nginx.conf`, `vercel.json` e `frontend/vercel.json`. Antes de incluir uma nova origem, confirme que ela é necessária e restrinja-a à diretiva correta (`connect-src`, `img-src`, `font-src` ou `style-src`). Não use curingas nem adicione `'unsafe-inline'` a `script-src`. + +A API responde com uma CSP ainda mais restritiva, apropriada para respostas JSON: não permite carregar recursos, executar scripts, enviar formulários ou ser incorporada em frames. + +## Conteúdo externo + +Descrições de vagas são convertidas em elementos React a partir de uma allowlist. Não use `dangerouslySetInnerHTML` para renderizar dados de vagas, perfis ou integrações externas. Links externos devem aceitar somente URLs `http` e `https`; protocolos executáveis e atributos de evento não devem ser propagados para o DOM. + +## Sessão + +Cookies de sessão são `HttpOnly` e usam `Secure` em produção, com `SameSite` definido. A CSP reduz o impacto de uma regressão de XSS, mas não substitui a validação e a renderização segura de dados. diff --git a/TESTING.md b/TESTING.md index e2280543..49691838 100644 --- a/TESTING.md +++ b/TESTING.md @@ -642,7 +642,7 @@ Executar antes de qualquer release: ### Prioridade 3 - Introduzir monitoramento de erros client/server. -- Expandir testes E2E para tema, responsividade e fluxo de filtros. +- Introduzir testes E2E automatizados (browser) para tema, responsividade e fluxo de filtros — hoje não há Playwright/Cypress nem qualquer suíte E2E versionada no repositório; os casos MOB-*/INT-* deste guia são roteiros manuais. --- diff --git a/backend/.env.example b/backend/.env.example index 37335da9..31c99851 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,73 +1,75 @@ +# Variáveis para rodar o backend localmente, FORA do Docker +# (npm run dev --workspace=backend). Copie para backend/.env e preencha os +# segredos. Se você sobe tudo via Docker Compose, use o .env da raiz. + # Runtime NODE_ENV=development PORT=3001 -# Base URL da aplicação -# Ex: APP_URL=http://localhost:3001/api -APP_URL= - -# Sessão -# Use uma string longa e secreta em produção -SESSION_SECRET= - -# CORS / frontend access (separar por vírgula) -CORS_ALLOWED_ORIGINS= - -# Rate limit de autenticação -AUTH_RATE_LIMIT_IP_MAX=20 -AUTH_RATE_LIMIT_ACCOUNT_MAX=5 -AUTH_RATE_LIMIT_WINDOW_SECONDS=900 - -# URL do frontend (usada para redirect pos-OAuth) +# URLs base +APP_URL=http://localhost:3001 FRONTEND_URL=http://localhost:5173 -# Session -# Use uma string longa e secreta em ambientes reais. -SESSION_SECRET=change-me-with-a-long-random-secret +# Sessão — obrigatória +# Qualquer string longa e aleatória. Gere uma com: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +SESSION_SECRET= -# Segurança / PII -# ENCRYPTION_MASTER_KEY deve ter 32 bytes em hex, ou seja, 64 caracteres hex. +# Segurança / criptografia de PII — obrigatórias +# ENCRYPTION_MASTER_KEY precisa ter 64 caracteres hexadecimais (32 bytes). +# Gere uma com o mesmo comando acima. ENCRYPTION_MASTER_KEY= ENCRYPTION_KEY_ID=default +# Qualquer string secreta não vazia (usada para gerar hashes pesquisáveis de e-mail/CPF). SEARCH_KEY= -# CORS / frontend access +# CORS +# Fonte da verdade das origens permitidas. Em produção, se vazio, cai apenas +# em *.candidate.app.br (localhost nunca entra por fallback em produção). CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174 -# Database / cache for backend running locally outside Docker +# Rate limit de autenticação — login e cadastro têm buckets próprios. +# Variável ausente ou inválida (<= 0 ou não numérica) usa o default indicado. +AUTH_RATE_LIMIT_IP_MAX=20 +AUTH_RATE_LIMIT_ACCOUNT_MAX=5 +AUTH_RATE_LIMIT_WINDOW_SECONDS=900 + +# Banco de dados / cache — aponte para um Postgres/Valkey acessíveis pelo host. DATABASE_URL=postgresql://vagas:vagas@localhost:5432/vagas VALKEY_URL=redis://localhost:6379/0 CACHE_TTL_MS=600000 -# Mantém GET /keywords ativo, mas bloqueia POST /keywords e publicação no kwsync. + +# Liga/desliga POST /keywords e o consumidor da fila scraper:keywords:pending +# no scraper-go (kwsync). Mantenha false em dev a menos que esteja testando isso. KWSYNC_ENABLED=false -# Scraping behavior -WAIT_BETWEEN_SEARCHES_MS=5000 -PAGE_TIMEOUT_MS=10000 -MAX_PAGES_PER_KEYWORD=5 +# E-mail transacional +# Vazio ⇒ provider no-op (apenas loga, não envia). Preencha com a chave da +# Resend para enviar de verdade. +EMAIL_API_KEY= +EMAIL_FROM_ADDRESS= +EMAIL_FROM_NAME= +EMAIL_QUEUE_ATTEMPTS=3 + +# Integração com o scraper-go +# Usada pelos adapters goScraper.ts/goKeywords.ts (fluxo de scraping/keywords direto). +GO_SCRAPER_URL=http://localhost:8081 +# Usada pelo scraperClient nos endpoints administrativos /admin/scrapers/*. +# É uma variável distinta de GO_SCRAPER_URL, mesmo apontando ao mesmo serviço. +SCRAPER_URL=http://localhost:8081 -# Legacy flags -HEADLESS=false -VIEWPORT_WIDTH=1280 -VIEWPORT_HEIGHT=800 +# Observabilidade +PROMETHEUS_URL=http://localhost:9090 -# Third-party API credentials +# Third-party API credentials usadas por rotas administrativas/keywords (opcional) ADZUNA_APP_ID= ADZUNA_APP_KEY= JOOBLE_API_KEY= -# OAuth credentials +# OAuth (opcional — só necessário para testar login social) GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= LINKEDIN_CLIENT_ID= LINKEDIN_CLIENT_SECRET= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= - -# Local scraper services -GO_SCRAPER_URL=http://localhost:8081 -SCRAPER_URL=http://localhost:8081 -PROMETHEUS_URL=http://localhost:9090 - -SCRAPER_URL=http://scraper-go:8081 -PROMETHEUS_URL=http://prometheus:9090 diff --git a/backend/drizzle/0014_hard_korath.sql b/backend/drizzle/0014_hard_korath.sql new file mode 100644 index 00000000..9531246e --- /dev/null +++ b/backend/drizzle/0014_hard_korath.sql @@ -0,0 +1,12 @@ +CREATE TABLE "application_notes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "saved_job_id" uuid NOT NULL, + "content" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "application_notes" ADD CONSTRAINT "application_notes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "application_notes" ADD CONSTRAINT "application_notes_saved_job_id_saved_jobs_id_fk" FOREIGN KEY ("saved_job_id") REFERENCES "public"."saved_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "application_notes_user_job_idx" ON "application_notes" USING btree ("user_id","saved_job_id"); diff --git a/backend/drizzle/meta/0014_snapshot.json b/backend/drizzle/meta/0014_snapshot.json new file mode 100644 index 00000000..16ad780a --- /dev/null +++ b/backend/drizzle/meta/0014_snapshot.json @@ -0,0 +1,1287 @@ +{ + "id": "849617ba-d9b9-4539-8b4a-bda2c9de83ce", + "prevId": "979ff559-fdaa-4fd2-ad0e-00019bbbd23e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_unique": { + "name": "accounts_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.application_events": { + "name": "application_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "saved_job_id": { + "name": "saved_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "from_status": { + "name": "from_status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "to_status": { + "name": "to_status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "application_events_saved_job_id_created_at_idx": { + "name": "application_events_saved_job_id_created_at_idx", + "columns": [ + { + "expression": "saved_job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "application_events_user_id_users_id_fk": { + "name": "application_events_user_id_users_id_fk", + "tableFrom": "application_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_events_saved_job_id_saved_jobs_id_fk": { + "name": "application_events_saved_job_id_saved_jobs_id_fk", + "tableFrom": "application_events", + "tableTo": "saved_jobs", + "columnsFrom": [ + "saved_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.application_notes": { + "name": "application_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "saved_job_id": { + "name": "saved_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "application_notes_user_job_idx": { + "name": "application_notes_user_job_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "saved_job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "application_notes_user_id_users_id_fk": { + "name": "application_notes_user_id_users_id_fk", + "tableFrom": "application_notes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_notes_saved_job_id_saved_jobs_id_fk": { + "name": "application_notes_saved_job_id_saved_jobs_id_fk", + "tableFrom": "application_notes", + "tableTo": "saved_jobs", + "columnsFrom": [ + "saved_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_role": { + "name": "actor_role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "credentials_user_id_users_id_fk": { + "name": "credentials_user_id_users_id_fk", + "tableFrom": "credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credentials_user_id_unique": { + "name": "credentials_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "credentials_email_unique": { + "name": "credentials_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "credentials_email_hash_unique": { + "name": "credentials_email_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "email_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keywords": { + "name": "keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "keywords_user_keyword_unique": { + "name": "keywords_user_keyword_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "keywords_user_id_users_id_fk": { + "name": "keywords_user_id_users_id_fk", + "tableFrom": "keywords", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_rules": { + "name": "permission_rules", + "schema": "", + "columns": { + "resource": { + "name": "resource", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "min_role": { + "name": "min_role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "permission_rules_resource_action_pk": { + "name": "permission_rules_resource_action_pk", + "columns": [ + "resource", + "action" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_jobs": { + "name": "saved_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_link": { + "name": "job_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'saved'" + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "saved_jobs_user_id_users_id_fk": { + "name": "saved_jobs_user_id_users_id_fk", + "tableFrom": "saved_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_notifications": { + "name": "user_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "notification_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'notification'" + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_notifications_user_created_at_idx": { + "name": "user_notifications_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_notifications_user_read_at_idx": { + "name": "user_notifications_user_read_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "keywords": { + "name": "keywords", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "search_location": { + "name": "search_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_language": { + "name": "search_language", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "remote_only": { + "name": "remote_only", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "job_types": { + "name": "job_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "email_notifications": { + "name": "email_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "career_checklist": { + "name": "career_checklist", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_preferences_user_id_unique": { + "name": "user_preferences_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name_encrypted": { + "name": "first_name_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name_encrypted": { + "name": "last_name_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name_encrypted": { + "name": "display_name_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_encrypted": { + "name": "email_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url_encrypted": { + "name": "avatar_url_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "phone_encrypted": { + "name": "phone_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpf": { + "name": "cpf", + "type": "varchar(14)", + "primaryKey": false, + "notNull": false + }, + "cpf_encrypted": { + "name": "cpf_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpf_hash": { + "name": "cpf_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "technologies": { + "name": "technologies", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "technologies_encrypted": { + "name": "technologies_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "technology_experiences_encrypted": { + "name": "technology_experiences_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "level_encrypted": { + "name": "level_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_blocked": { + "name": "is_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_hash_unique": { + "name": "users_email_hash_unique", + "columns": [ + { + "expression": "email_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.notification_channel": { + "name": "notification_channel", + "schema": "public", + "values": [ + "notification", + "message" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "job_saved", + "job_applied", + "job_status_changed", + "high_match", + "mentor", + "system" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "support", + "admin", + "super_admin" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 9ad8e6cf..797f1641 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1786569164248, "tag": "0013_chunky_wonder_man", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1788029383364, + "tag": "0014_hard_korath", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/index.js b/backend/index.js deleted file mode 100644 index 73a5ab2d..00000000 --- a/backend/index.js +++ /dev/null @@ -1,8 +0,0 @@ -import "dotenv/config"; -import { run } from "./src/app.js"; -import { logError } from "./src/logger.js"; - -run().catch((error) => { - logError(error?.stack || error?.message || "Falha inesperada na execucao"); - process.exitCode = 1; -}); diff --git a/backend/package.json b/backend/package.json index 1556c60e..05e5e10d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,12 +2,10 @@ "name": "backend", "version": "1.0.0", "description": "Backend para o projeto ", - "main": "index.ts", + "main": "src/server.ts", "scripts": { "start": "tsx src/server.ts", "dev": "tsx src/server.ts", - "scraper": "tsx index.ts", - "scraper:watch": "nodemon index.ts", "api": "tsx src/server.ts", "test": "vitest run", "test:coverage": "vitest run --coverage", @@ -16,6 +14,7 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", + "db:seed": "tsx src/scripts/seed.ts", "security:backfill-user-pii": "tsx src/scripts/backfillUserPii.ts", "clear-cache": "tsx src/cache/clearCache.ts" }, @@ -67,7 +66,6 @@ "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^4.1.8", "drizzle-kit": "^0.31.10", - "nodemon": "^3.1.14", "supertest": "^7.2.2", "tsx": "^4.22.4", "typescript": "^6.0.3", diff --git a/backend/src/app.ts b/backend/src/app.ts index ff33e72b..6400d934 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,5 +1,5 @@ import cors from "cors"; -import express, { NextFunction, Request, Response } from "express"; +import express, { NextFunction, Request, Response, Router } from "express"; import { register } from "./metrics/metrics"; import { corsOptions } from "./middleware/cors"; import { errorHandler } from "./middleware/errorHandler"; @@ -37,6 +37,20 @@ export function createJobsApiApp() { app.set("trust proxy", 1); + const apiV1 = Router(); + apiV1.use("/auth", withSession, authRoutes); + apiV1.use("/users", withSession, requireAuth, userRoutes); + apiV1.use("/jobs", withSession, requireAuth, jobsRoutes); + apiV1.use("/keywords", withSession, requireAuth, keywordsRoutes); + apiV1.use("/notifications", withSession, requireAuth, notificationsRoutes); + apiV1.use("/saved-jobs", withSession, requireAuth, savedJobsRoutes); + apiV1.use("/admin", withSession, supportRoutes); + apiV1.use("/admin", withSession, adminRoutes); + apiV1.use("/admin", withSession, superAdminRoutes); + + app.use("/api/v1", apiV1); + + // Compatibilidade temporária para clientes ainda não migrados para /api/v1. app.use("/auth", withSession, authRoutes); app.use("/users", withSession, requireAuth, userRoutes); app.use("/jobs", withSession, requireAuth, jobsRoutes); @@ -47,17 +61,9 @@ export function createJobsApiApp() { app.use("/admin", withSession, adminRoutes); app.use("/admin", withSession, superAdminRoutes); - /** - * @swagger - * /health: - * get: - * summary: Verifica se a API está online - * tags: [System] - * responses: - * 200: - * description: API funcionando - */ - app.get("/health", (_req, res) => res.json({ ok: true })); + const healthHandler = (_req: Request, res: Response) => res.json({ ok: true }); + app.get("/api/v1/health", healthHandler); + app.get("/health", healthHandler); app.get("/metrics", async (_req, res) => { res.set("Content-Type", register.contentType); diff --git a/backend/src/db/schema/applicationNotes.ts b/backend/src/db/schema/applicationNotes.ts new file mode 100644 index 00000000..c5061069 --- /dev/null +++ b/backend/src/db/schema/applicationNotes.ts @@ -0,0 +1,24 @@ +import { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { savedJobs } from "./savedJobs"; +import { users } from "./users"; + +export const applicationNotes = pgTable( + "application_notes", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + savedJobId: uuid("saved_job_id") + .notNull() + .references(() => savedJobs.id, { onDelete: "cascade" }), + content: text("content").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [index("application_notes_user_job_idx").on(table.userId, table.savedJobId)], +); + +export type ApplicationNote = InferSelectModel; +export type NewApplicationNote = InferInsertModel; diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index ca3cfde2..07c1f0a4 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -1,5 +1,6 @@ export * from "./accounts"; export * from "./applicationEvents"; +export * from "./applicationNotes"; export * from "./auditLogs"; export * from "./credentials"; export * from "./keywords"; diff --git a/backend/src/middleware/cors.ts b/backend/src/middleware/cors.ts index 886adb14..ea11e4bd 100644 --- a/backend/src/middleware/cors.ts +++ b/backend/src/middleware/cors.ts @@ -1,20 +1,41 @@ import cors from "cors"; +import { logWarn } from "../logger"; -const DEFAULT_ALLOWED_ORIGINS = [ +const PROD_ALLOWED_ORIGINS = [ "https://candidate.app.br", "https://admin.candidate.app.br", "https://support.candidate.app.br", "https://api.candidate.app.br", +]; + +const DEV_ALLOWED_ORIGINS = [ "http://localhost:5173", "http://localhost:5174", ]; +/** + * Origens permitidas. `CORS_ALLOWED_ORIGINS` (lista separada por vírgula) é a + * fonte da verdade. Sem a env: + * - em `production`: cai só nas origens de produção e loga um aviso — o + * `localhost` nunca entra no allowlist de produção por fallback silencioso; + * - fora de produção: prod + `localhost` (dev e admin locais). + */ function parseAllowedOrigins(value: string | undefined): Set { const configured = String(value ?? "") .split(",") .map((o) => o.trim()) .filter(Boolean); - return new Set(configured.length > 0 ? configured : DEFAULT_ALLOWED_ORIGINS); + + if (configured.length > 0) return new Set(configured); + + if (process.env.NODE_ENV === "production") { + logWarn( + "CORS_ALLOWED_ORIGINS não definida em produção; usando apenas as origens de produção padrão.", + ); + return new Set(PROD_ALLOWED_ORIGINS); + } + + return new Set([...PROD_ALLOWED_ORIGINS, ...DEV_ALLOWED_ORIGINS]); } export const corsOptions: cors.CorsOptions = { diff --git a/backend/src/middleware/rateLimit.ts b/backend/src/middleware/rateLimit.ts index d3092e7d..7a3c912e 100644 --- a/backend/src/middleware/rateLimit.ts +++ b/backend/src/middleware/rateLimit.ts @@ -152,6 +152,18 @@ export const authAccountRateLimiter = createRateLimiter({ keyGenerator: normalizedEmail, }); +/** + * Limita o cadastro por e-mail (bucket próprio, separado do de login) para + * conter criação de contas em massa e enumeração de e-mails já registrados. + * Sem e-mail no corpo o limiter é ignorado e a validação Zod trata o 400. + */ +export const authRegisterRateLimiter = createRateLimiter({ + name: "auth:register", + max: authAccountMax, + windowSeconds: authWindowSeconds, + keyGenerator: normalizedEmail, +}); + export function resetInMemoryRateLimitStore(): void { memoryStore.clear(); } diff --git a/backend/src/middleware/securityHeaders.ts b/backend/src/middleware/securityHeaders.ts index b6b18a09..770b500d 100644 --- a/backend/src/middleware/securityHeaders.ts +++ b/backend/src/middleware/securityHeaders.ts @@ -1,10 +1,33 @@ import { NextFunction, Request, Response } from "express"; +export const apiContentSecurityPolicy = + "default-src 'none'; base-uri 'none'; object-src 'none'; form-action 'none'; frame-ancestors 'none'"; + +export const swaggerContentSecurityPolicy = + "default-src 'self'; base-uri 'none'; object-src 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'"; + +/** + * Cabeçalhos de segurança aplicados a todas as respostas. + * + * A API usa uma CSP sem fontes de recursos; a documentação em `/docs` usa + * uma política limitada aos assets locais do Swagger. + * + * `Strict-Transport-Security` só é enviado sobre HTTPS (atrás do proxy, + * `req.secure` reflete `x-forwarded-proto` graças a `app.set("trust proxy", 1)`) + * ou quando `NODE_ENV=production`, onde o tráfego é sempre HTTPS. `preload` + * fica de fora de propósito: entrar na lista de preload é uma decisão difícil + * de reverter e deve ser um opt-in explícito do time. + */ export function securityHeaders( - _req: Request, + req: Request, res: Response, next: NextFunction, ): void { + const contentSecurityPolicy = req.path.startsWith("/docs") + ? swaggerContentSecurityPolicy + : apiContentSecurityPolicy; + + res.setHeader("Content-Security-Policy", contentSecurityPolicy); res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "DENY"); res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); @@ -12,5 +35,13 @@ export function securityHeaders( "Permissions-Policy", "camera=(), microphone=(), geolocation=()", ); + + if (req.secure || process.env.NODE_ENV === "production") { + res.setHeader( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains", + ); + } + next(); } diff --git a/backend/src/modules/admin/users/adminUsers.service.ts b/backend/src/modules/admin/users/adminUsers.service.ts index b19fe1dc..66002996 100644 --- a/backend/src/modules/admin/users/adminUsers.service.ts +++ b/backend/src/modules/admin/users/adminUsers.service.ts @@ -4,8 +4,8 @@ import type { ChangeRoleInput, PaginatedUsers, ResetPasswordInput, - User, } from "./adminUsers.types"; +import type { PublicUser } from "../../users/users.mapper"; export class AdminUsersService { constructor(private readonly repository: AdminUsersRepository) {} @@ -14,13 +14,13 @@ export class AdminUsersService { return this.repository.findMany(filters); } - async getUserById(id: string): Promise { + async getUserById(id: string): Promise { const user = await this.repository.findById(id); if (!user) throw new Error("Usuário não encontrado"); return user; } - async blockUser(id: string): Promise { + async blockUser(id: string): Promise { const user = await this.repository.findById(id); if (!user) throw new Error("Usuário não encontrado"); if (user.isBlocked) throw new Error("Usuário já está bloqueado"); @@ -30,7 +30,7 @@ export class AdminUsersService { return updated; } - async unblockUser(id: string): Promise { + async unblockUser(id: string): Promise { const user = await this.repository.findById(id); if (!user) throw new Error("Usuário não encontrado"); if (!user.isBlocked) throw new Error("Usuário não está bloqueado"); @@ -40,7 +40,7 @@ export class AdminUsersService { return updated; } - async changeRole({ userId, newRole }: ChangeRoleInput): Promise { + async changeRole({ userId, newRole }: ChangeRoleInput): Promise { const user = await this.repository.findById(userId); if (!user) throw new Error("Usuário não encontrado"); if (user.role === newRole) throw new Error("Usuário já possui esta role"); @@ -60,7 +60,7 @@ export class AdminUsersService { await this.repository.resetPassword({ userId, newPassword }); } - async deleteUser(id: string): Promise { + async deleteUser(id: string): Promise { const user = await this.repository.findById(id); if (!user) throw new Error("Usuário não encontrado"); diff --git a/backend/src/modules/admin/users/adminUsers.types.ts b/backend/src/modules/admin/users/adminUsers.types.ts index bdd48e4b..102cd3cc 100644 --- a/backend/src/modules/admin/users/adminUsers.types.ts +++ b/backend/src/modules/admin/users/adminUsers.types.ts @@ -1,11 +1,10 @@ import { z } from "zod"; import type { User } from "../../../db/schema/users"; +import type { PublicUser } from "../../users/users.mapper"; import type { Role } from "../permissions/roles"; -// Re-exportando o tipo original do banco, caso outros arquivos precisem export type { User }; -// --- AdminUserFilters --- export const AdminUserFiltersSchema = z.object({ search: z.string().optional(), // busca por nome, username ou email role: z.custom().optional(), @@ -15,25 +14,20 @@ export const AdminUserFiltersSchema = z.object({ }); export type AdminUserFilters = z.infer; - -// --- PaginatedUsers --- export const PaginatedUsersSchema = z.object({ - data: z.array(z.custom()), + data: z.array(z.custom()), total: z.number().int().min(0), limit: z.number().int().positive(), offset: z.number().int().min(0), }); export type PaginatedUsers = z.infer; - -// --- ChangeRoleInput --- export const ChangeRoleInputSchema = z.object({ userId: z.string().uuid("ID de usuário inválido"), newRole: z.custom(), }); export type ChangeRoleInput = z.infer; - // --- ResetPasswordInput --- export const ResetPasswordInputSchema = z.object({ userId: z.string().uuid("ID de usuário inválido"), diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index b5f5dbae..7d6ba103 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -1,5 +1,5 @@ -import { User } from "../../db/schema/users"; import { logError } from "../../logger"; +import type { PublicUser } from "../users/users.mapper"; import { emailService } from "../email/email.service"; import type { AuthCallbackParams, @@ -27,7 +27,7 @@ export class AuthService { state, callbackUrl, }: AuthCallbackParams): Promise<{ - user: User; + user: PublicUser; session: Session; }> { const profile = await this.getProfileFromProvider({ @@ -63,13 +63,13 @@ export class AuthService { * Dispara o e-mail de boas-vindas para um usuário recém-criado via login * social. Falha nunca derruba o login (EMAIL-08): erro é apenas logado. */ - private async sendWelcomeEmail(user: User): Promise { + private async sendWelcomeEmail(user: PublicUser): Promise { if (!user.email) return; try { await emailService.sendWelcome({ email: user.email, - name: user.displayName ?? user.username, + name: user.displayName ?? user.username ?? "Usuário", }); } catch (error) { logError("Falha ao disparar e-mail de boas-vindas no login social.", { @@ -100,7 +100,7 @@ export class AuthService { async createSession(user: { id: string; - role: User["role"]; + role: PublicUser["role"]; }): Promise { return { userId: user.id, diff --git a/backend/src/modules/auth/connections.controller.ts b/backend/src/modules/auth/connections.controller.ts index df3cd747..f3f536f9 100644 --- a/backend/src/modules/auth/connections.controller.ts +++ b/backend/src/modules/auth/connections.controller.ts @@ -18,7 +18,10 @@ export class ConnectionsController { const userId = req.session.userId as string; const provider = req.params.provider; - if (!(SUPPORTED_PROVIDERS as readonly string[]).includes(provider)) { + if ( + typeof provider !== "string" || + !(SUPPORTED_PROVIDERS as readonly string[]).includes(provider) + ) { throw AppError.validation("Provider inválido."); } diff --git a/backend/src/modules/auth/credentials.service.ts b/backend/src/modules/auth/credentials.service.ts index ef50c67f..09efb424 100644 --- a/backend/src/modules/auth/credentials.service.ts +++ b/backend/src/modules/auth/credentials.service.ts @@ -2,7 +2,6 @@ import * as argon2 from "argon2"; import { db } from "../../db/client"; import { userPreferences } from "../../db/schema"; import { credentials } from "../../db/schema/credentials"; -import type { User } from "../../db/schema/users"; import { AppError } from "../../lib/errors"; import { logError } from "../../logger"; import { emailService } from "../email/email.service"; @@ -17,6 +16,7 @@ import { RegisterInput, RegisterSchema, } from "../types/credentials.types"; +import type { PublicUser } from "../users/users.mapper"; import { UsersRepository } from "../users/users.repository"; const argonOptions = { @@ -27,14 +27,14 @@ const argonOptions = { }; export class CredentialsService { - async findById(id: string): Promise { + async findById(id: string): Promise { const user = await new UsersRepository().findById(id); return user ?? null; } async register( input: RegisterInput, - ): Promise<{ user: User; session: Session }> { + ): Promise<{ user: PublicUser; session: Session }> { const { email, password, name, phone, cpf, technologies, level } = RegisterSchema.parse(input); const normalizedEmail = normalizeEmail(email); @@ -86,8 +86,8 @@ export class CredentialsService { // E-mail de boas-vindas: falha nunca derruba o registro (EMAIL-08). try { await emailService.sendWelcome({ - email: user.email, - name: user.displayName ?? user.username, + email: normalizedEmail, + name: name?.trim() || normalizedEmail.split("@")[0], }); } catch (error) { logError("Falha ao disparar e-mail de boas-vindas.", { @@ -99,7 +99,9 @@ export class CredentialsService { return { user, session: { userId: user.id, role: user.role } }; } - async login(input: LoginInput): Promise<{ user: User; session: Session }> { + async login( + input: LoginInput, + ): Promise<{ user: PublicUser; session: Session }> { const { email, password } = LoginSchema.parse(input); const normalizedEmail = normalizeEmail(email); const emailHash = generateSearchableHash(normalizedEmail); diff --git a/backend/src/modules/jobs/services/jobMatch.service.ts b/backend/src/modules/jobs/services/jobMatch.service.ts index 587a8474..5f8224b2 100644 --- a/backend/src/modules/jobs/services/jobMatch.service.ts +++ b/backend/src/modules/jobs/services/jobMatch.service.ts @@ -1,5 +1,5 @@ -import { SavedJob, User } from "../../../db/schema"; -import { toPublicUser } from "../../users/users.mapper"; +import { SavedJob } from "../../../db/schema"; +import type { PublicUser } from "../../users/users.mapper"; export type TechnologyExperience = { name: string; @@ -70,9 +70,8 @@ function jobMatchText(job: MatchableJob) { ); } -function parseTechnologiesFromUser(user: User): TechnologyExperience[] { - const publicUser = toPublicUser(user); - const experiences = publicUser.technologyExperiences; +function parseTechnologiesFromUser(user: PublicUser): TechnologyExperience[] { + const experiences = user.technologyExperiences; if (Array.isArray(experiences)) { return experiences @@ -86,13 +85,13 @@ function parseTechnologiesFromUser(user: User): TechnologyExperience[] { .filter((item): item is TechnologyExperience => Boolean(item)); } - return (publicUser.technologies ?? []) + return (user.technologies ?? []) .map((name) => name.trim()) .filter(Boolean) .map((name) => ({ name, years: 1 })); } -export function getUserMatchTechnologies(user: User | undefined | null) { +export function getUserMatchTechnologies(user: PublicUser | undefined | null) { if (!user) return []; return parseTechnologiesFromUser(user); } diff --git a/backend/src/modules/savedJobs/applicationNotes.controller.ts b/backend/src/modules/savedJobs/applicationNotes.controller.ts new file mode 100644 index 00000000..303d76eb --- /dev/null +++ b/backend/src/modules/savedJobs/applicationNotes.controller.ts @@ -0,0 +1,30 @@ +import { Request, Response } from "express"; +import { getIronSession } from "iron-session"; +import { AppError } from "../../lib/errors"; +import { sessionOptions } from "../../lib/session"; +import { Session } from "../types/auth.types"; +import { ApplicationNotesService } from "./applicationNotes.service"; + +export class ApplicationNotesController { + constructor(private readonly service: ApplicationNotesService) {} + + private async userId(req: Request, res: Response): Promise { + const session = await getIronSession(req, res, sessionOptions); + if (!session.userId) throw AppError.unauthorized(); + return session.userId; + } + + async list(req: Request, res: Response) { + return res.json(await this.service.list(await this.userId(req, res), req.params.id as string)); + } + async create(req: Request, res: Response) { + return res.status(201).json(await this.service.create(await this.userId(req, res), req.params.id as string, req.body.content)); + } + async update(req: Request, res: Response) { + return res.json(await this.service.update(await this.userId(req, res), req.params.id as string, req.params.noteId as string, req.body.content)); + } + async delete(req: Request, res: Response) { + await this.service.delete(await this.userId(req, res), req.params.id as string, req.params.noteId as string); + return res.status(204).send(); + } +} diff --git a/backend/src/modules/savedJobs/applicationNotes.service.ts b/backend/src/modules/savedJobs/applicationNotes.service.ts new file mode 100644 index 00000000..8304c73b --- /dev/null +++ b/backend/src/modules/savedJobs/applicationNotes.service.ts @@ -0,0 +1,54 @@ +import { and, asc, eq } from "drizzle-orm"; +import { db } from "../../db/client"; +import { applicationNotes, ApplicationNote, savedJobs } from "../../db/schema"; +import { DB } from "../../db/types/types"; +import { AppError } from "../../lib/errors"; + +export class ApplicationNotesService { + constructor(private readonly tx: DB = db) {} + + private async ensureJobOwner(userId: string, savedJobId: string): Promise { + const [job] = await this.tx + .select({ id: savedJobs.id }) + .from(savedJobs) + .where(and(eq(savedJobs.id, savedJobId), eq(savedJobs.userId, userId))) + .limit(1); + if (!job) throw AppError.notFound("Vaga não encontrada"); + } + + async list(userId: string, savedJobId: string): Promise { + await this.ensureJobOwner(userId, savedJobId); + return this.tx.query.applicationNotes.findMany({ + where: (note, { and, eq }) => + and(eq(note.userId, userId), eq(note.savedJobId, savedJobId)), + orderBy: (note, { asc }) => [asc(note.createdAt), asc(note.id)], + }); + } + + async create(userId: string, savedJobId: string, content: string): Promise { + await this.ensureJobOwner(userId, savedJobId); + const [note] = await this.tx + .insert(applicationNotes) + .values({ userId, savedJobId, content }) + .returning(); + return note; + } + + async update(userId: string, savedJobId: string, noteId: string, content: string): Promise { + const [note] = await this.tx + .update(applicationNotes) + .set({ content, updatedAt: new Date() }) + .where(and(eq(applicationNotes.id, noteId), eq(applicationNotes.savedJobId, savedJobId), eq(applicationNotes.userId, userId))) + .returning(); + if (!note) throw AppError.notFound("Nota não encontrada"); + return note; + } + + async delete(userId: string, savedJobId: string, noteId: string): Promise { + const [note] = await this.tx + .delete(applicationNotes) + .where(and(eq(applicationNotes.id, noteId), eq(applicationNotes.savedJobId, savedJobId), eq(applicationNotes.userId, userId))) + .returning({ id: applicationNotes.id }); + if (!note) throw AppError.notFound("Nota não encontrada"); + } +} diff --git a/backend/src/modules/savedJobs/schemas/savedJobs.schemas.ts b/backend/src/modules/savedJobs/schemas/savedJobs.schemas.ts index 31c290ff..9012e938 100644 --- a/backend/src/modules/savedJobs/schemas/savedJobs.schemas.ts +++ b/backend/src/modules/savedJobs/schemas/savedJobs.schemas.ts @@ -20,5 +20,13 @@ export const savedJobParamsSchema = z.object({ id: z.string().uuid("ID da vaga salva inválido."), }); +export const applicationNoteSchema = z.object({ + content: z.string().trim().min(1, "A nota não pode ficar vazia.").max(5000), +}); + +export const applicationNoteParamsSchema = savedJobParamsSchema.extend({ + noteId: z.string().uuid("ID da nota inválido."), +}); + export type CreateSavedJobInput = z.infer; export type UpdateSavedJobInput = z.infer; diff --git a/backend/src/modules/users/users.mapper.ts b/backend/src/modules/users/users.mapper.ts index 486b2494..5aae08d9 100644 --- a/backend/src/modules/users/users.mapper.ts +++ b/backend/src/modules/users/users.mapper.ts @@ -106,31 +106,59 @@ export function toUserUpdateValues(data: UpdateProfileData): Partial { return values; } -export function toPublicUser( - user: User, -): User & { technologyExperiences?: unknown[] | null } { +export type PublicUser = Omit< + User, + | "emailEncrypted" + | "emailHash" + | "firstNameEncrypted" + | "lastNameEncrypted" + | "displayNameEncrypted" + | "avatarUrlEncrypted" + | "phoneEncrypted" + | "cpfEncrypted" + | "cpfHash" + | "technologiesEncrypted" + | "technologyExperiencesEncrypted" + | "levelEncrypted" +> & { technologyExperiences?: unknown[] | null }; + +export function toPublicUser(user: User): PublicUser { + const { + emailEncrypted, + emailHash, + firstNameEncrypted, + lastNameEncrypted, + displayNameEncrypted, + avatarUrlEncrypted, + phoneEncrypted, + cpfEncrypted, + cpfHash, + technologiesEncrypted, + technologyExperiencesEncrypted, + levelEncrypted, + ...safeUser + } = user; + return { - ...user, - email: user.emailEncrypted ? decryptText(user.emailEncrypted) : user.email, - firstName: user.firstNameEncrypted - ? decryptText(user.firstNameEncrypted) + ...safeUser, + email: emailEncrypted ? decryptText(emailEncrypted) : user.email, + firstName: firstNameEncrypted + ? decryptText(firstNameEncrypted) : user.firstName, - lastName: user.lastNameEncrypted - ? decryptText(user.lastNameEncrypted) + lastName: lastNameEncrypted + ? decryptText(lastNameEncrypted) : user.lastName, - displayName: user.displayNameEncrypted - ? decryptText(user.displayNameEncrypted) + displayName: displayNameEncrypted + ? decryptText(displayNameEncrypted) : user.displayName, - avatarUrl: user.avatarUrlEncrypted - ? decryptText(user.avatarUrlEncrypted) + avatarUrl: avatarUrlEncrypted + ? decryptText(avatarUrlEncrypted) : user.avatarUrl, - phone: user.phoneEncrypted ? decryptText(user.phoneEncrypted) : user.phone, - cpf: user.cpfEncrypted ? decryptText(user.cpfEncrypted) : user.cpf, + phone: phoneEncrypted ? decryptText(phoneEncrypted) : user.phone, + cpf: cpfEncrypted ? decryptText(cpfEncrypted) : user.cpf, technologies: - parseEncryptedArray(user.technologiesEncrypted) ?? user.technologies, - technologyExperiences: parseEncryptedArray( - user.technologyExperiencesEncrypted, - ), - level: user.levelEncrypted ? decryptText(user.levelEncrypted) : user.level, + parseEncryptedArray(technologiesEncrypted) ?? user.technologies, + technologyExperiences: parseEncryptedArray(technologyExperiencesEncrypted), + level: levelEncrypted ? decryptText(levelEncrypted) : user.level, }; } diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts index e1c92f08..5f759153 100644 --- a/backend/src/modules/users/users.service.ts +++ b/backend/src/modules/users/users.service.ts @@ -1,20 +1,24 @@ import { db } from "../../db/client"; -import { User, UserPreferences, userPreferences, users } from "../../db/schema"; +import { UserPreferences, userPreferences } from "../../db/schema"; import { DB } from "../../db/types/types"; import { ownedBy } from "../../lib/authorization/ownership"; import { AppError } from "../../lib/errors"; import { UpdateProfileData } from "../types/user.types"; +import type { PublicUser } from "./users.mapper"; import { UpdatePreferencesData } from "./schemas/user.schemas"; import { UsersRepository } from "./users.repository"; export class UsersService { constructor(private readonly tx: DB = db) {} - async getUserById(id: string): Promise { + async getUserById(id: string): Promise { return (await new UsersRepository(this.tx).findById(id)) ?? undefined; } - async updateProfile(userId: string, data: UpdateProfileData): Promise { + async updateProfile( + userId: string, + data: UpdateProfileData, + ): Promise { const updated = await new UsersRepository(this.tx).updateProfile( userId, data, diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 5fabeec8..1900c669 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { authAccountRateLimiter, authIpRateLimiter, + authRegisterRateLimiter, } from "../middleware/rateLimit"; import { requireAuth } from "../middleware/requireAuth"; import { validate } from "../middleware/validate"; @@ -52,6 +53,8 @@ router.delete("/connections/:provider", requireAuth, (req, res, next) => { // Credentials router.post( "/register", + authIpRateLimiter, + authRegisterRateLimiter, validate({ body: RegisterSchema }), (req, res, next) => { credentialsController.register(req, res).catch(next); diff --git a/backend/src/routes/jobs.routes.ts b/backend/src/routes/jobs.routes.ts index 0f8283e3..bf34dceb 100644 --- a/backend/src/routes/jobs.routes.ts +++ b/backend/src/routes/jobs.routes.ts @@ -3,17 +3,4 @@ import { searchJobsController } from "../modules/jobs/controllers/searchJobs.con export const jobsRoutes = Router(); -/** - * @swagger - * /api/jobs/search: - * get: - * summary: Busca vagas em memória RAM no Valkey usando índices invertidos e interseção - * tags: [Jobs] - * parameters: - * - in: query - * name: keywords - * schema: - * type: string - * description: 'Termos para filtrar (ex: "react,node") separados por vírgula' - */ jobsRoutes.get("/search", searchJobsController); diff --git a/backend/src/routes/keywords.routes.ts b/backend/src/routes/keywords.routes.ts index 23d1d8f9..a97753a8 100644 --- a/backend/src/routes/keywords.routes.ts +++ b/backend/src/routes/keywords.routes.ts @@ -8,16 +8,6 @@ import { getConfig } from "../config"; export const keywordsRoutes = Router(); -/** - * @swagger - * /api/keywords: - * get: - * summary: Retorna palavras-chave configuradas - * tags: [Keywords] - * responses: - * 200: - * description: Lista de keywords - */ keywordsRoutes.get("/", async (req, res) => { const userId = req.session?.userId; if (!userId) return res.status(401).json({ message: "Não autenticado." }); @@ -38,29 +28,6 @@ keywordsRoutes.get("/", async (req, res) => { } }); -/** - * @swagger - * /api/keywords: - * post: - * summary: Enfileira uma keyword para o Go processar - * tags: [Keywords] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * keyword: - * type: string - * responses: - * 202: - * description: Keyword enfileirada — o Go decide se persiste - * 403: - * description: Submissão de keywords por usuário desabilitada - * 400: - * description: Dados inválidos - */ keywordsRoutes.post("/", async (req, res) => { const userId = req.session?.userId; if (!userId) return res.status(401).json({ message: "Não autenticado." }); diff --git a/backend/src/routes/savedJobs.routes.ts b/backend/src/routes/savedJobs.routes.ts index 8be2b77b..04a13ad6 100644 --- a/backend/src/routes/savedJobs.routes.ts +++ b/backend/src/routes/savedJobs.routes.ts @@ -1,16 +1,21 @@ import { Router } from "express"; import { validate } from "../middleware/validate"; import { SavedJobsController } from "../modules/savedJobs/savedJobs.controller"; +import { ApplicationNotesController } from "../modules/savedJobs/applicationNotes.controller"; +import { ApplicationNotesService } from "../modules/savedJobs/applicationNotes.service"; import { SavedJobsService } from "../modules/savedJobs/savedJobs.service"; import { createSavedJobSchema, savedJobParamsSchema, updateSavedJobSchema, + applicationNoteParamsSchema, + applicationNoteSchema, } from "../modules/savedJobs/schemas/savedJobs.schemas"; const router = Router(); const service = new SavedJobsService(); const controller = new SavedJobsController(service); +const notesController = new ApplicationNotesController(new ApplicationNotesService()); router.get("/", (req, res, next) => { controller.getAll(req, res).catch(next); @@ -21,6 +26,18 @@ router.get("/:id", (req, res, next) => { router.get("/:id/events", (req, res, next) => { controller.getEvents(req, res).catch(next); }); +router.get("/:id/notes", validate({ params: savedJobParamsSchema }), (req, res, next) => { + notesController.list(req, res).catch(next); +}); +router.post("/:id/notes", validate({ params: savedJobParamsSchema, body: applicationNoteSchema }), (req, res, next) => { + notesController.create(req, res).catch(next); +}); +router.patch("/:id/notes/:noteId", validate({ params: applicationNoteParamsSchema, body: applicationNoteSchema }), (req, res, next) => { + notesController.update(req, res).catch(next); +}); +router.delete("/:id/notes/:noteId", validate({ params: applicationNoteParamsSchema }), (req, res, next) => { + notesController.delete(req, res).catch(next); +}); router.post("/", validate({ body: createSavedJobSchema }), (req, res, next) => { controller.create(req, res).catch(next); }); diff --git a/backend/src/scripts/seed.ts b/backend/src/scripts/seed.ts new file mode 100644 index 00000000..dd7b72ef --- /dev/null +++ b/backend/src/scripts/seed.ts @@ -0,0 +1,241 @@ +import "dotenv/config"; +import * as argon2 from "argon2"; +import { and, eq } from "drizzle-orm"; +import { db, pool } from "../db/client"; +import { + applicationEvents, + credentials, + savedJobs, + userPreferences, + users, +} from "../db/schema"; +import type { JobStatus } from "../db/schema/savedJobs"; +import type { UserRole } from "../db/schema/users"; +import { encryptText } from "../lib/security/encryption"; +import { normalizeEmail } from "../lib/security/normalization"; +import { generateSearchableHash } from "../lib/security/searchableHash"; +const argonOptions = { + type: argon2.argon2id, + memoryCost: 65536, + timeCost: 3, + parallelism: 4, +}; + +type SeedUserSpec = { + username: string; + displayName: string; + email: string; + password: string; + role: UserRole; +}; + +const SEED_USERS: SeedUserSpec[] = [ + { + username: "local.developer", + displayName: "Local Developer", + email: "dev@localhost.test", + password: "Dev@123456", + role: "user", + }, + { + username: "local.admin", + displayName: "Local Admin", + email: "admin@localhost.test", + password: "Admin@123456", + role: "admin", + }, +]; + +type SeedSavedJobSpec = { + jobLink: string; + jobTitle: string; + company: string; + location: string; + source: string; + keyword: string; + status: JobStatus; + appliedAt?: Date; + notes?: string; +}; + +const SEED_SAVED_JOBS: SeedSavedJobSpec[] = [ + { + jobLink: "https://example.com/jobs/seed-frontend-pleno", + jobTitle: "Desenvolvedor(a) Frontend Pleno", + company: "Vagas Full Tech", + location: "Remoto", + source: "seed-local", + keyword: "frontend", + status: "saved", + }, + { + jobLink: "https://example.com/jobs/seed-backend-node", + jobTitle: "Desenvolvedor(a) Backend Node.js", + company: "Vagas Full Tech", + location: "São Paulo, SP", + source: "seed-local", + keyword: "node", + status: "applied", + appliedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), + notes: "[Seed local] Recrutador respondeu por e-mail; retornar até sexta.", + }, + { + jobLink: "https://example.com/jobs/seed-fullstack-react", + jobTitle: "Engenheiro(a) Fullstack React", + company: "Vagas Full Tech", + location: "Remoto", + source: "seed-local", + keyword: "react", + status: "interviewing", + appliedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + notes: "[Seed local] Entrevista técnica marcada; revisar system design.", + }, +]; + +async function upsertSeedUser(spec: SeedUserSpec): Promise { + const normalizedEmail = normalizeEmail(spec.email); + const emailHash = generateSearchableHash(normalizedEmail); + + const existingCredential = await db.query.credentials.findFirst({ + where: eq(credentials.emailHash, emailHash), + }); + + if (existingCredential) { + console.log(`- usuário já existe, mantendo: ${spec.email}`); + return existingCredential.userId; + } + + const passwordHash = await argon2.hash(spec.password, argonOptions); + + const userId = await db.transaction(async (tx) => { + const [createdUser] = await tx + .insert(users) + .values({ + username: spec.username, + displayNameEncrypted: encryptText(spec.displayName), + emailEncrypted: encryptText(normalizedEmail), + emailHash, + emailVerified: true, + role: spec.role, + }) + .returning(); + + await tx.insert(credentials).values({ + userId: createdUser.id, + email: encryptText(normalizedEmail), + emailHash, + passwordHash, + }); + + await tx.insert(userPreferences).values({ userId: createdUser.id }); + + return createdUser.id; + }); + + console.log(`+ usuário criado: ${spec.email} (role=${spec.role})`); + return userId; +} + +async function upsertSeedSavedJob( + userId: string, + spec: SeedSavedJobSpec, +): Promise { + const existing = await db.query.savedJobs.findFirst({ + where: and( + eq(savedJobs.userId, userId), + eq(savedJobs.jobLink, spec.jobLink), + ), + }); + + if (existing) { + console.log(`- vaga salva já existe, mantendo: ${spec.jobLink}`); + return existing.id; + } + + const [created] = await db + .insert(savedJobs) + .values({ + userId, + jobLink: spec.jobLink, + jobTitle: spec.jobTitle, + company: spec.company, + location: spec.location, + source: spec.source, + keyword: spec.keyword, + status: spec.status, + appliedAt: spec.appliedAt, + notes: spec.notes, + }) + .returning(); + + console.log(`+ vaga salva criada: ${spec.jobTitle} (status=${spec.status})`); + return created.id; +} + +async function upsertApplicationEvent( + userId: string, + savedJobId: string, + fromStatus: JobStatus, + toStatus: JobStatus, +): Promise { + const existing = await db.query.applicationEvents.findFirst({ + where: and( + eq(applicationEvents.savedJobId, savedJobId), + eq(applicationEvents.fromStatus, fromStatus), + eq(applicationEvents.toStatus, toStatus), + ), + }); + + if (existing) { + console.log(`- evento já existe, mantendo: ${fromStatus} -> ${toStatus}`); + return; + } + + await db.insert(applicationEvents).values({ + userId, + savedJobId, + type: "status_changed", + fromStatus, + toStatus, + }); + + console.log(`+ evento criado: ${fromStatus} -> ${toStatus}`); +} + +async function main() { + console.log("Iniciando seed de desenvolvimento local..."); + + const devUserSpec = SEED_USERS.find((spec) => spec.role === "user")!; + const otherUserSpecs = SEED_USERS.filter((spec) => spec !== devUserSpec); + + const devUserId = await upsertSeedUser(devUserSpec); + for (const spec of otherUserSpecs) { + await upsertSeedUser(spec); + } + + const savedJobIds: string[] = []; + for (const spec of SEED_SAVED_JOBS) { + savedJobIds.push(await upsertSeedSavedJob(devUserId, spec)); + } + + // "saved" -> "applied" na segunda vaga, "applied" -> "interviewing" na terceira, + // reproduzindo a trilha real que o front cria ao mover o status de uma vaga salva. + await upsertApplicationEvent(devUserId, savedJobIds[1], "saved", "applied"); + await upsertApplicationEvent( + devUserId, + savedJobIds[2], + "applied", + "interviewing", + ); + + console.log("Seed concluído."); +} + +main() + .catch((error) => { + console.error("Falha ao rodar o seed:", error); + process.exitCode = 1; + }) + .finally(async () => { + await pool.end(); + }); diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index e8b81df7..ba542663 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -1,17 +1,137 @@ import path from "path"; import swaggerJsdoc from "swagger-jsdoc"; +const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }); +const response = (name: string) => ({ $ref: `#/components/responses/${name}` }); +const auth = [{ cookieAuth: [] }]; +const json = (description: string, schema: object, example?: object) => ({ + description, + content: { "application/json": { schema, ...(example ? { example } : {}) } }, +}); +const body = (schema: object, example: object) => ({ + required: true, + content: { "application/json": { schema, example } }, +}); +const id = { + in: "path", + name: "id", + required: true, + schema: { type: "string", format: "uuid" }, + example: "3fa85f64-5717-4562-b3fc-2c963f66afa6", +}; + const options: swaggerJsdoc.Options = { definition: { openapi: "3.0.0", info: { - title: "Jobs API", + title: "Candidate API", version: "1.0.0", + description: "Contrato HTTP da Candidate. Todos os paths usam o prefixo /api/v1.", + }, + servers: [{ url: "/api/v1", description: "API v1" }], + tags: ["System", "Auth", "Users", "Jobs", "Saved jobs", "Notifications", "Keywords", "Admin"].map((name) => ({ name })), + components: { + securitySchemes: { + cookieAuth: { type: "apiKey", in: "cookie", name: "candidate_session", description: "Sessão criada no login." }, + }, + schemas: { + Error: { type: "object", required: ["code", "message"], properties: { code: { type: "string", example: "UNAUTHORIZED" }, message: { type: "string", example: "Autenticação necessária." } } }, + UserProfile: { type: "object", required: ["id", "email"], properties: { id: { type: "string", format: "uuid" }, email: { type: "string", format: "email", example: "ana@exemplo.com" }, displayName: { type: "string", nullable: true, example: "Ana Souza" }, firstName: { type: "string", nullable: true }, lastName: { type: "string", nullable: true }, username: { type: "string", nullable: true }, avatarUrl: { type: "string", format: "uri", nullable: true }, technologies: { type: "array", items: { type: "string" } }, level: { type: "string", nullable: true } } }, + Session: { type: "object", required: ["userId", "role"], properties: { userId: { type: "string", format: "uuid" }, role: { type: "string", enum: ["user", "support", "admin", "super_admin"] } } }, + AuthResponse: { type: "object", required: ["user", "session"], properties: { user: ref("UserProfile"), session: ref("Session") } }, + LoginRequest: { type: "object", required: ["email", "password"], properties: { email: { type: "string", format: "email", example: "ana@exemplo.com" }, password: { type: "string", format: "password", minLength: 1, example: "senha-segura" } } }, + RegisterRequest: { type: "object", required: ["email", "password"], properties: { email: { type: "string", format: "email" }, password: { type: "string", format: "password", minLength: 8, maxLength: 128 }, name: { type: "string", maxLength: 100, example: "Ana Souza" }, phone: { type: "string", example: "+55 11 99999-9999" }, cpf: { type: "string", example: "123.456.789-09" }, technologies: { type: "array", items: { type: "string" }, example: ["React", "TypeScript"] }, level: { type: "string", example: "Pleno" } } }, + ProfileUpdateRequest: { type: "object", properties: { displayName: { type: "string", nullable: true }, firstName: { type: "string", nullable: true }, lastName: { type: "string", nullable: true }, username: { type: "string", pattern: "^[a-z0-9_]+$" }, avatarUrl: { type: "string", format: "uri", nullable: true }, phone: { type: "string", nullable: true }, cpf: { type: "string", nullable: true }, technologies: { type: "array", maxItems: 30, items: { type: "string" } }, technologyExperiences: { type: "array", items: { type: "object", properties: { name: { type: "string" }, years: { type: "number", minimum: 0, maximum: 50 } } } }, level: { type: "string", nullable: true } } }, + Preferences: { type: "object", properties: { keywords: { type: "array", items: { type: "string" }, example: ["react", "node"] }, searchLocation: { type: "string", nullable: true }, searchLanguage: { type: "string", minLength: 2, maxLength: 2 }, remoteOnly: { type: "boolean" }, jobTypes: { type: "array", items: { type: "string", enum: ["Remoto", "Híbrido", "Presencial"] } }, emailNotifications: { type: "boolean" }, careerChecklist: { type: "array", items: { type: "object", additionalProperties: true } } } }, + Job: { type: "object", required: ["id", "jobTitle", "company", "jobLink"], properties: { id: { type: "string", example: "job-123" }, jobTitle: { type: "string", example: "Desenvolvedor Backend" }, company: { type: "string", example: "Candidate" }, location: { type: "string", example: "Remoto" }, jobLink: { type: "string", format: "uri", example: "https://empresa.exemplo/vagas/123" }, source: { type: "string", example: "LinkedIn" }, keyword: { type: "string", example: "node" } } }, + JobSearchResponse: { type: "object", required: ["jobs"], properties: { jobs: { type: "array", items: ref("Job") }, total: { type: "integer", example: 1 }, source: { type: "string", example: "valkey_filtered_by_keywords" } } }, + SavedJobRequest: { type: "object", required: ["jobLink"], properties: { jobLink: { type: "string", format: "uri" }, jobTitle: { type: "string" }, company: { type: "string" }, location: { type: "string" }, source: { type: "string" }, keyword: { type: "string" }, status: { type: "string", enum: ["saved", "applied", "interviewing", "rejected", "accepted"] }, appliedAt: { type: "string", format: "date-time" }, notes: { type: "string" } } }, + SavedJob: { allOf: [ref("SavedJobRequest"), { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" }, createdAt: { type: "string", format: "date-time" }, updatedAt: { type: "string", format: "date-time" } } }] }, + ApplicationEvent: { type: "object", properties: { id: { type: "string", format: "uuid" }, type: { type: "string", example: "status_changed" }, fromStatus: { type: "string", nullable: true }, toStatus: { type: "string" }, createdAt: { type: "string", format: "date-time" } } }, + Notification: { type: "object", required: ["id", "channel", "message", "createdAt"], properties: { id: { type: "string", format: "uuid" }, channel: { type: "string", enum: ["notification", "message"] }, type: { type: "string" }, title: { type: "string" }, message: { type: "string" }, readAt: { type: "string", format: "date-time", nullable: true }, createdAt: { type: "string", format: "date-time" }, entityType: { type: "string", nullable: true }, entityId: { type: "string", nullable: true } } }, + NotificationListResponse: { type: "object", required: ["notifications", "unreadCount"], properties: { notifications: { type: "array", items: ref("Notification") }, unreadCount: { type: "integer", minimum: 0, example: 1 } } }, + MutationResult: { type: "object", properties: { ok: { type: "boolean", example: true }, updated: { type: "integer", example: 1 }, deleted: { type: "integer", example: 1 } } }, + AdminUser: { type: "object", properties: { id: { type: "string", format: "uuid" }, email: { type: "string", format: "email" }, role: { type: "string" }, isBlocked: { type: "boolean" } } }, + }, + responses: { + BadRequest: json("Dados inválidos.", ref("Error"), { code: "VALIDATION_ERROR", message: "Dados de entrada inválidos." }), + Unauthorized: json("Autenticação necessária.", ref("Error"), { code: "UNAUTHORIZED", message: "Autenticação necessária." }), + Forbidden: json("Permissão insuficiente.", ref("Error"), { code: "FORBIDDEN", message: "Permissão insuficiente." }), + NotFound: json("Recurso não encontrado.", ref("Error"), { code: "NOT_FOUND", message: "Recurso não encontrado." }), + InternalError: json("Erro interno.", ref("Error"), { code: "INTERNAL_ERROR", message: "Erro inesperado." }), + }, + }, + paths: { + "/health": { get: { tags: ["System"], summary: "Verifica a disponibilidade", responses: { 200: json("API disponível.", { type: "object", properties: { ok: { type: "boolean" } } }, { ok: true }) } } }, + "/auth/register": { post: { tags: ["Auth"], summary: "Cria conta e sessão", requestBody: body(ref("RegisterRequest"), { email: "ana@exemplo.com", password: "senha-segura", name: "Ana Souza" }), responses: { 201: json("Conta criada.", ref("AuthResponse")), 400: response("BadRequest"), 500: response("InternalError") } } }, + "/auth/login": { post: { tags: ["Auth"], summary: "Inicia sessão", requestBody: body(ref("LoginRequest"), { email: "ana@exemplo.com", password: "senha-segura" }), responses: { 200: json("Sessão iniciada.", ref("AuthResponse")), 400: response("BadRequest"), 401: response("Unauthorized") } } }, + "/auth/logout": { post: { tags: ["Auth"], summary: "Encerra sessão", responses: { 200: json("Sessão encerrada.", ref("MutationResult"), { ok: true }) } } }, + "/auth/me": { get: { tags: ["Auth"], summary: "Consulta sessão atual", security: auth, responses: { 200: json("Sessão atual.", { type: "object", properties: { user: ref("UserProfile") } }), 401: response("Unauthorized") } } }, + "/auth/{provider}/url": { get: { tags: ["Auth"], summary: "Obtém URL OAuth", parameters: [{ in: "path", name: "provider", required: true, schema: { type: "string", enum: ["google", "github", "linkedin"] } }], responses: { 200: json("URL de autorização.", { type: "object", properties: { url: { type: "string", format: "uri" } } }, { url: "https://accounts.example/authorize" }), 400: response("BadRequest") } } }, + "/auth/{provider}/callback": { get: { tags: ["Auth"], summary: "Processa callback OAuth", parameters: [{ in: "path", name: "provider", required: true, schema: { type: "string", enum: ["google", "github", "linkedin"] } }], responses: { 302: { description: "Redireciona após autenticação." }, 400: response("BadRequest") } } }, + "/auth/connections": { get: { tags: ["Auth"], summary: "Lista conexões OAuth", security: auth, responses: { 200: json("Conexões ativas.", { type: "array", items: { type: "object", properties: { provider: { type: "string" } } } }), 401: response("Unauthorized") } } }, + "/auth/connections/{provider}": { delete: { tags: ["Auth"], summary: "Desconecta provedor OAuth", security: auth, parameters: [{ in: "path", name: "provider", required: true, schema: { type: "string", enum: ["google", "github", "linkedin"] } }], responses: { 204: { description: "Conexão removida." }, 401: response("Unauthorized"), 404: response("NotFound") } } }, + "/users/profile": { get: { tags: ["Users"], summary: "Consulta perfil", security: auth, responses: { 200: json("Perfil.", ref("UserProfile")), 401: response("Unauthorized"), 404: response("NotFound") } }, patch: { tags: ["Users"], summary: "Atualiza perfil", security: auth, requestBody: body(ref("ProfileUpdateRequest"), { displayName: "Ana Souza", technologies: ["React"] }), responses: { 200: json("Perfil atualizado.", ref("UserProfile")), 400: response("BadRequest"), 401: response("Unauthorized") } } }, + "/users/preferences": { get: { tags: ["Users"], summary: "Consulta preferências", security: auth, responses: { 200: json("Preferências.", ref("Preferences")), 401: response("Unauthorized"), 404: response("NotFound") } }, post: { tags: ["Users"], summary: "Cria preferências", security: auth, requestBody: body(ref("Preferences"), { keywords: ["react"], remoteOnly: true }), responses: { 201: json("Preferências criadas.", ref("Preferences")), 400: response("BadRequest"), 401: response("Unauthorized") } }, patch: { tags: ["Users"], summary: "Atualiza preferências", security: auth, requestBody: body(ref("Preferences"), { emailNotifications: false }), responses: { 200: json("Preferências atualizadas.", ref("Preferences")), 400: response("BadRequest"), 401: response("Unauthorized") } } }, + "/jobs/search": { get: { tags: ["Jobs"], summary: "Busca vagas", security: auth, parameters: [{ in: "query", name: "keywords", schema: { type: "string" }, example: "react,node" }], responses: { 200: json("Vagas encontradas.", ref("JobSearchResponse"), { jobs: [{ id: "job-123", jobTitle: "Desenvolvedor Backend", company: "Candidate", jobLink: "https://empresa.exemplo/vagas/123" }], total: 1 }), 401: response("Unauthorized") } } }, + "/saved-jobs": { + get: { tags: ["Saved jobs"], summary: "Lista vagas salvas", security: auth, responses: { 200: json("Vagas salvas.", { type: "array", items: ref("SavedJob") }), 401: response("Unauthorized") } }, + post: { tags: ["Saved jobs"], summary: "Salva vaga", security: auth, requestBody: body(ref("SavedJobRequest"), { jobLink: "https://empresa.exemplo/vagas/123", jobTitle: "Desenvolvedor Backend", status: "saved" }), responses: { 201: json("Vaga salva.", ref("SavedJob")), 400: response("BadRequest"), 401: response("Unauthorized") } }, + }, + "/saved-jobs/{id}": { + get: { tags: ["Saved jobs"], summary: "Consulta vaga salva", security: auth, parameters: [id], responses: { 200: json("Vaga salva.", ref("SavedJob")), 401: response("Unauthorized"), 404: response("NotFound") } }, + patch: { tags: ["Saved jobs"], summary: "Atualiza vaga salva", security: auth, parameters: [id], requestBody: body(ref("SavedJobRequest"), { status: "applied", notes: "Candidatura enviada." }), responses: { 200: json("Vaga atualizada.", ref("SavedJob")), 400: response("BadRequest"), 401: response("Unauthorized"), 404: response("NotFound") } }, + delete: { tags: ["Saved jobs"], summary: "Remove vaga salva", security: auth, parameters: [id], responses: { 204: { description: "Vaga removida." }, 401: response("Unauthorized"), 404: response("NotFound") } }, + }, + "/saved-jobs/{id}/events": { get: { tags: ["Saved jobs"], summary: "Lista histórico da candidatura", security: auth, parameters: [id], responses: { 200: json("Eventos.", { type: "array", items: ref("ApplicationEvent") }), 401: response("Unauthorized"), 404: response("NotFound") } } }, + "/notifications": { + get: { tags: ["Notifications"], summary: "Lista notificações ou mensagens", security: auth, parameters: [{ in: "query", name: "channel", schema: { type: "string", enum: ["notification", "message"] } }, { in: "query", name: "unreadOnly", schema: { type: "boolean" } }, { in: "query", name: "limit", schema: { type: "integer", minimum: 1, maximum: 100, default: 30 } }], responses: { 200: json("Feed.", ref("NotificationListResponse"), { notifications: [], unreadCount: 0 }), 400: response("BadRequest"), 401: response("Unauthorized") } }, + delete: { tags: ["Notifications"], summary: "Limpa notificações", security: auth, parameters: [{ in: "query", name: "channel", schema: { type: "string", enum: ["notification", "message"] } }], responses: { 200: json("Notificações removidas.", ref("MutationResult")), 400: response("BadRequest"), 401: response("Unauthorized") } }, + }, + "/notifications/read-all": { patch: { tags: ["Notifications"], summary: "Marca todas como lidas", security: auth, parameters: [{ in: "query", name: "channel", schema: { type: "string", enum: ["notification", "message"] } }], responses: { 200: json("Notificações atualizadas.", ref("MutationResult")), 400: response("BadRequest"), 401: response("Unauthorized") } } }, + "/notifications/{id}/read": { patch: { tags: ["Notifications"], summary: "Marca uma notificação como lida", security: auth, parameters: [id], responses: { 200: json("Notificação atualizada.", ref("Notification")), 401: response("Unauthorized"), 404: response("NotFound") } } }, + "/keywords": { + get: { + tags: ["Keywords"], summary: "Lista keywords", security: auth, + responses: { + 200: json("Keywords.", { type: "object", properties: { + ok: { type: "boolean" }, + keywords: { type: "array", items: { type: "object", properties: { keyword: { type: "string" }, source: { type: "string" } } } }, + } }), + 401: response("Unauthorized"), + }, + }, + post: { + tags: ["Keywords"], summary: "Enfileira keyword", security: auth, + requestBody: body({ type: "object", required: ["keyword"], properties: { keyword: { type: "string" } } }, { keyword: "golang" }), + responses: { 202: json("Keyword enfileirada.", ref("MutationResult")), 400: response("BadRequest"), 401: response("Unauthorized"), 403: response("Forbidden") }, + }, + }, + "/admin/dashboard": { get: { tags: ["Admin"], summary: "Consulta dashboard administrativo", security: auth, responses: { 200: json("Resumo administrativo.", { type: "object", additionalProperties: true }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, + "/admin/users": { get: { tags: ["Admin"], summary: "Lista usuários administrativos", security: auth, responses: { 200: json("Usuários.", { type: "array", items: ref("AdminUser") }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, + "/admin/users/{id}": { get: { tags: ["Admin"], summary: "Consulta usuário", security: auth, parameters: [id], responses: { 200: json("Usuário.", ref("AdminUser")), 401: response("Unauthorized"), 403: response("Forbidden"), 404: response("NotFound") } }, delete: { tags: ["Admin"], summary: "Exclui usuário", security: auth, parameters: [id], responses: { 204: { description: "Usuário removido." }, 401: response("Unauthorized"), 403: response("Forbidden"), 404: response("NotFound") } } }, + "/admin/users/{id}/block": { patch: { tags: ["Admin"], summary: "Bloqueia usuário", security: auth, parameters: [id], responses: { 200: json("Usuário bloqueado.", ref("AdminUser")), 401: response("Unauthorized"), 403: response("Forbidden"), 404: response("NotFound") } } }, + "/admin/users/{id}/unblock": { patch: { tags: ["Admin"], summary: "Desbloqueia usuário", security: auth, parameters: [id], responses: { 200: json("Usuário desbloqueado.", ref("AdminUser")), 401: response("Unauthorized"), 403: response("Forbidden"), 404: response("NotFound") } } }, + "/admin/users/{id}/reset": { post: { tags: ["Admin"], summary: "Solicita redefinição de senha", security: auth, parameters: [id], responses: { 200: json("Redefinição solicitada.", ref("MutationResult")), 401: response("Unauthorized"), 403: response("Forbidden"), 404: response("NotFound") } } }, + "/admin/users/{id}/role": { patch: { tags: ["Admin"], summary: "Altera o papel de um usuário", security: auth, parameters: [id], requestBody: body({ type: "object", required: ["role"], properties: { role: { type: "string", enum: ["user", "support", "admin", "super_admin"] } } }, { role: "support" }), responses: { 200: json("Papel atualizado.", ref("AdminUser")), 400: response("BadRequest"), 401: response("Unauthorized"), 403: response("Forbidden"), 404: response("NotFound") } } }, + "/admin/scrapers": { get: { tags: ["Admin"], summary: "Lista scrapers", security: auth, responses: { 200: json("Scrapers.", { type: "array", items: { type: "object", additionalProperties: true } }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, + "/admin/scrapers/run": { post: { tags: ["Admin"], summary: "Dispara todos os scrapers", security: auth, responses: { 202: json("Execução iniciada.", ref("MutationResult")), 401: response("Unauthorized"), 403: response("Forbidden") } } }, + "/admin/scrapers/{id}/run": { post: { tags: ["Admin"], summary: "Dispara um scraper", security: auth, parameters: [id], responses: { 202: json("Execução iniciada.", ref("MutationResult")), 401: response("Unauthorized"), 403: response("Forbidden"), 404: response("NotFound") } } }, + "/admin/scrapers/status": { get: { tags: ["Admin"], summary: "Consulta status dos scrapers", security: auth, responses: { 200: json("Status dos scrapers.", { type: "object", additionalProperties: true }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, + "/admin/scrapers/jobs": { get: { tags: ["Admin"], summary: "Lista vagas coletadas", security: auth, responses: { 200: json("Vagas coletadas.", { type: "array", items: ref("Job") }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, + "/admin/scrapers/jobs/count": { get: { tags: ["Admin"], summary: "Conta vagas coletadas", security: auth, responses: { 200: json("Contagem de vagas.", { type: "object", properties: { count: { type: "integer", example: 42 } } }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, + "/admin/observability/health": { get: { tags: ["Admin"], summary: "Consulta saúde operacional", security: auth, responses: { 200: json("Saúde operacional.", { type: "object", additionalProperties: true }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, + "/admin/observability/metrics": { get: { tags: ["Admin"], summary: "Consulta métricas operacionais", security: auth, responses: { 200: json("Métricas operacionais.", { type: "object", additionalProperties: true }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, + "/admin/observability/dashboards": { get: { tags: ["Admin"], summary: "Lista dashboards operacionais", security: auth, responses: { 200: json("Dashboards operacionais.", { type: "array", items: { type: "object", additionalProperties: true } }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, + "/admin/audit": { get: { tags: ["Admin"], summary: "Consulta logs de auditoria", security: auth, responses: { 200: json("Logs.", { type: "object", additionalProperties: true }), 401: response("Unauthorized"), 403: response("Forbidden") } } }, + "/admin/permissions/rules": { + get: { tags: ["Admin"], summary: "Lista regras de permissão", security: auth, responses: { 200: json("Regras de permissão.", { type: "array", items: { type: "object", additionalProperties: true } }), 401: response("Unauthorized"), 403: response("Forbidden") } }, + patch: { tags: ["Admin"], summary: "Atualiza regras de permissão", security: auth, requestBody: body({ type: "object", additionalProperties: true }, {}), responses: { 200: json("Regras atualizadas.", { type: "array", items: { type: "object", additionalProperties: true } }), 400: response("BadRequest"), 401: response("Unauthorized"), 403: response("Forbidden") } }, + }, + "/admin/jobs/cache": { delete: { tags: ["Admin"], summary: "Limpa o cache de vagas", security: auth, responses: { 200: json("Cache limpo.", ref("MutationResult")), 401: response("Unauthorized"), 403: response("Forbidden"), 500: response("InternalError") } } }, }, }, apis: [path.resolve("src/**/*.ts")], }; -const swaggerSpec = swaggerJsdoc(options); - -export default swaggerSpec; +export default swaggerJsdoc(options); diff --git a/backend/tests/integration/routes/auth.routes.test.ts b/backend/tests/integration/routes/auth.routes.test.ts index af213c76..dce1de7b 100644 --- a/backend/tests/integration/routes/auth.routes.test.ts +++ b/backend/tests/integration/routes/auth.routes.test.ts @@ -435,6 +435,31 @@ describe("Integration - Auth Routes", () => { .send(registerPayload) .expect(500); }); + + it("bloqueia cadastros repetidos para o mesmo email com 429", async () => { + vi.mocked(getIronSession).mockResolvedValue( + fixtureCredentialsSession as any, + ); + + for (let attempt = 0; attempt < 5; attempt += 1) { + await request(app) + .post(`${BASE}/register`) + .send(registerPayload) + .expect(201); + } + + const res = await request(app) + .post(`${BASE}/register`) + .send(registerPayload) + .expect(429); + + expect(res.body).toHaveProperty( + "error", + "Muitas tentativas. Tente novamente mais tarde.", + ); + expect(res.headers["retry-after"]).toBeDefined(); + expect(mockCredentialsService.register).toHaveBeenCalledTimes(5); + }); }); // ── POST /login ─────────────────────────────────────────────────────────── diff --git a/backend/tests/integration/routes/connections.routes.test.ts b/backend/tests/integration/routes/connections.routes.test.ts index 6347ae45..1b9b65e2 100644 --- a/backend/tests/integration/routes/connections.routes.test.ts +++ b/backend/tests/integration/routes/connections.routes.test.ts @@ -84,4 +84,20 @@ describe("connections routes", () => { expect(res.body.code).toBe("VALIDATION_ERROR"); expect(mocks.disconnectProvider).not.toHaveBeenCalled(); }); + + it("recusa provider não escalar antes de desconectar", async () => { + const controller = new ConnectionsController(); + + await expect( + controller.disconnect( + { + session: { userId: "user-A" }, + params: { provider: ["google", "github"] }, + } as any, + { json: vi.fn() } as any, + ), + ).rejects.toMatchObject({ code: "VALIDATION_ERROR" }); + + expect(mocks.disconnectProvider).not.toHaveBeenCalled(); + }); }); diff --git a/backend/tests/unit/app.test.ts b/backend/tests/unit/app.test.ts index 0993f6bd..e385b7b5 100644 --- a/backend/tests/unit/app.test.ts +++ b/backend/tests/unit/app.test.ts @@ -159,6 +159,13 @@ describe("jobsApiApp", () => { expect(res.body).toEqual({ ok: true }); }); + it("GET /api/v1/health retorna ok", async () => { + const app = createJobsApiApp(); + const res = await request(app).get("/api/v1/health").expect(200); + + expect(res.body).toEqual({ ok: true }); + }); + // ── CORS ────────────────────────────────────────────────────────────── it("permite CORS para origem autorizada", async () => { @@ -232,6 +239,60 @@ describe("jobsApiApp", () => { expect(res.headers["referrer-policy"]).toBe( "strict-origin-when-cross-origin", ); + expect(res.headers["permissions-policy"]).toBe( + "camera=(), microphone=(), geolocation=()", + ); + }); + + it("não envia HSTS sobre HTTP fora de produção", async () => { + const app = createJobsApiApp(); + const res = await request(app).get("/health").expect(200); + + expect(res.headers["strict-transport-security"]).toBeUndefined(); + }); + + it("envia HSTS quando NODE_ENV=production", async () => { + const previous = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + try { + const app = createJobsApiApp(); + const res = await request(app).get("/health").expect(200); + + expect(res.headers["strict-transport-security"]).toBe( + "max-age=31536000; includeSubDomains", + ); + } finally { + process.env.NODE_ENV = previous; + } + }); + + it("em produção sem CORS_ALLOWED_ORIGINS bloqueia localhost mas libera origem de produção", async () => { + const previousEnv = process.env.NODE_ENV; + const previousOrigins = process.env.CORS_ALLOWED_ORIGINS; + process.env.NODE_ENV = "production"; + delete process.env.CORS_ALLOWED_ORIGINS; + try { + const app = createJobsApiApp(); + + await request(app) + .get("/health") + .set("Origin", "https://candidate.app.br") + .expect(200); + + const blocked = await request(app) + .get("/health") + .set("Origin", "http://localhost:5173") + .expect(403); + + expect(blocked.body.message).toBe("Origem não permitida."); + } finally { + process.env.NODE_ENV = previousEnv; + if (previousOrigins === undefined) { + delete process.env.CORS_ALLOWED_ORIGINS; + } else { + process.env.CORS_ALLOWED_ORIGINS = previousOrigins; + } + } }); // ── jobs/search ─────────────────────────────────────────────────────── @@ -252,6 +313,13 @@ describe("jobsApiApp", () => { expect(res.body.source).toContain("valkey_filtered_by_keywords"); }); + it("GET /api/v1/jobs/search usa a rota versionada", async () => { + const app = createJobsApiApp(); + const res = await request(app).get("/api/v1/jobs/search").expect(200); + + expect(res.body.jobs).toHaveLength(2); + }); + it("GET /jobs/search sem keywords usa índice global", async () => { const app = createJobsApiApp(); const res = await request(app).get("/jobs/search").expect(200); diff --git a/backend/tests/unit/middleware/securityHeaders.test.ts b/backend/tests/unit/middleware/securityHeaders.test.ts new file mode 100644 index 00000000..8265512d --- /dev/null +++ b/backend/tests/unit/middleware/securityHeaders.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vitest"; +import { + apiContentSecurityPolicy, + securityHeaders, + swaggerContentSecurityPolicy, +} from "../../../src/middleware/securityHeaders"; + +describe("securityHeaders", () => { + it("aplica uma CSP restritiva e os demais headers de proteção", () => { + const setHeader = vi.fn(); + const next = vi.fn(); + + securityHeaders({ path: "/health" } as any, { setHeader } as any, next); + + expect(setHeader).toHaveBeenCalledWith( + "Content-Security-Policy", + apiContentSecurityPolicy, + ); + expect(apiContentSecurityPolicy).toContain("default-src 'none'"); + expect(apiContentSecurityPolicy).toContain("object-src 'none'"); + expect(apiContentSecurityPolicy).toContain("frame-ancestors 'none'"); + expect(setHeader).toHaveBeenCalledWith("X-Content-Type-Options", "nosniff"); + expect(setHeader).toHaveBeenCalledWith("X-Frame-Options", "DENY"); + expect(next).toHaveBeenCalledOnce(); + }); + + it("mantem o Swagger funcional com uma CSP limitada a documentacao", () => { + const setHeader = vi.fn(); + const next = vi.fn(); + + securityHeaders({ path: "/docs" } as any, { setHeader } as any, next); + + expect(setHeader).toHaveBeenCalledWith( + "Content-Security-Policy", + swaggerContentSecurityPolicy, + ); + expect(swaggerContentSecurityPolicy).toContain("default-src 'self'"); + expect(swaggerContentSecurityPolicy).toContain("script-src 'self' 'unsafe-inline'"); + }); +}); diff --git a/backend/tests/unit/modules/auth/auth.service.test.ts b/backend/tests/unit/modules/auth/auth.service.test.ts index 93820370..e919c55f 100644 --- a/backend/tests/unit/modules/auth/auth.service.test.ts +++ b/backend/tests/unit/modules/auth/auth.service.test.ts @@ -216,5 +216,20 @@ describe("AuthService", () => { profile: mockProfile, }); }); + + it("uses a fallback name when the social profile has no name", async () => { + mocks.exchangeCode.mockResolvedValueOnce(mockProfile); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: { ...mockUser, displayName: null, username: null }, + isNewUser: true, + }); + + await service.handleCallback(validCallbackParams); + + expect(mocks.sendWelcome).toHaveBeenCalledWith({ + email: mockUser.email, + name: "Usuário", + }); + }); }); }); diff --git a/backend/tests/unit/modules/auth/credentials.service.test.ts b/backend/tests/unit/modules/auth/credentials.service.test.ts index 5ff325fe..2583b1f0 100644 --- a/backend/tests/unit/modules/auth/credentials.service.test.ts +++ b/backend/tests/unit/modules/auth/credentials.service.test.ts @@ -213,8 +213,8 @@ describe("CredentialsService", () => { await service.register(registerInput); expect(mocks.sendWelcome).toHaveBeenCalledWith({ - email: mockUser.email, - name: mockUser.displayName, + email: registerInput.email, + name: registerInput.name, }); }); diff --git a/backend/tests/unit/modules/jobs/jobMatch.service.test.ts b/backend/tests/unit/modules/jobs/jobMatch.service.test.ts index 6eb3c756..42cb6e5b 100644 --- a/backend/tests/unit/modules/jobs/jobMatch.service.test.ts +++ b/backend/tests/unit/modules/jobs/jobMatch.service.test.ts @@ -1,51 +1,26 @@ import { describe, expect, it } from "vitest"; -import type { User } from "../../../../src/db/schema"; -import { encryptText } from "../../../../src/lib/security/encryption"; +import type { PublicUser } from "../../../../src/modules/users/users.mapper"; import { getUserMatchTechnologies, jobNotificationIdentity, scoreJobWithTechnologies, } from "../../../../src/modules/jobs/jobMatch.service"; -const originalEnv = { - ENCRYPTION_MASTER_KEY: process.env.ENCRYPTION_MASTER_KEY, - ENCRYPTION_KEY_ID: process.env.ENCRYPTION_KEY_ID, - SEARCH_KEY: process.env.SEARCH_KEY, -}; - -function setValidSecurityEnv() { - process.env.ENCRYPTION_MASTER_KEY = - "0000000000000000000000000000000000000000000000000000000000000000"; - process.env.ENCRYPTION_KEY_ID = "job-match-test"; - process.env.SEARCH_KEY = "job-match-search-key"; -} - -function baseUser(overrides: Partial = {}): User { +function basePublicUser(overrides: Partial = {}): PublicUser { return { id: "user-1", firstName: null, - firstNameEncrypted: null, lastName: null, - lastNameEncrypted: null, displayName: null, - displayNameEncrypted: null, username: "user", email: "user@example.com", - emailEncrypted: null, - emailHash: null, emailVerified: false, avatarUrl: null, - avatarUrlEncrypted: null, phone: null, - phoneEncrypted: null, cpf: null, - cpfEncrypted: null, - cpfHash: null, technologies: null, - technologiesEncrypted: null, - technologyExperiencesEncrypted: null, + technologyExperiences: null, level: null, - levelEncrypted: null, role: "user", isBlocked: false, createdAt: new Date("2026-01-01T00:00:00.000Z"), @@ -61,32 +36,24 @@ describe("jobMatch.service", () => { expect(getUserMatchTechnologies(undefined)).toEqual([]); }); - it("extrai experiências criptografadas do usuário", () => { - setValidSecurityEnv(); - - const user = baseUser({ - technologyExperiencesEncrypted: encryptText( - JSON.stringify([ - { name: "TypeScript", years: 4 }, - { name: "Node.js", years: -1 }, - { name: "", years: 10 }, - null, - ]), - ), + it("extrai experiências de tecnologia do usuário (já decifradas)", () => { + const user = basePublicUser({ + technologyExperiences: [ + { name: "TypeScript", years: 4 }, + { name: "Node.js", years: -1 }, + { name: "", years: 10 }, + null, + ], }); expect(getUserMatchTechnologies(user)).toEqual([ { name: "TypeScript", years: 4 }, { name: "Node.js", years: 0 }, ]); - - process.env.ENCRYPTION_MASTER_KEY = originalEnv.ENCRYPTION_MASTER_KEY; - process.env.ENCRYPTION_KEY_ID = originalEnv.ENCRYPTION_KEY_ID; - process.env.SEARCH_KEY = originalEnv.SEARCH_KEY; }); it("usa technologies como fallback quando não há experiências", () => { - const user = baseUser({ + const user = basePublicUser({ technologies: ["React", " ", "Node.js"], }); diff --git a/backend/tests/unit/modules/users/users.mapper.test.ts b/backend/tests/unit/modules/users/users.mapper.test.ts index f6842e3c..3c40b9e6 100644 --- a/backend/tests/unit/modules/users/users.mapper.test.ts +++ b/backend/tests/unit/modules/users/users.mapper.test.ts @@ -210,6 +210,42 @@ describe("users.mapper", () => { }); }); + it("strips raw encrypted/hash fields from the public user object", () => { + setValidSecurityEnv(); + + const publicUser = toPublicUser( + baseUser({ + emailEncrypted: encryptText("ada@example.com"), + emailHash: "hash", + firstNameEncrypted: encryptText("Ada"), + lastNameEncrypted: encryptText("Lovelace"), + displayNameEncrypted: encryptText("Ada Lovelace"), + avatarUrlEncrypted: encryptText("https://example.com/ada.png"), + phoneEncrypted: encryptText("+5534999999999"), + cpfEncrypted: encryptText("12345678901"), + cpfHash: "hash", + technologiesEncrypted: encryptText(JSON.stringify(["TypeScript"])), + technologyExperiencesEncrypted: encryptText( + JSON.stringify([{ name: "TypeScript", years: 4 }]), + ), + levelEncrypted: encryptText("pleno"), + }), + ); + + expect(publicUser).not.toHaveProperty("emailEncrypted"); + expect(publicUser).not.toHaveProperty("emailHash"); + expect(publicUser).not.toHaveProperty("firstNameEncrypted"); + expect(publicUser).not.toHaveProperty("lastNameEncrypted"); + expect(publicUser).not.toHaveProperty("displayNameEncrypted"); + expect(publicUser).not.toHaveProperty("avatarUrlEncrypted"); + expect(publicUser).not.toHaveProperty("phoneEncrypted"); + expect(publicUser).not.toHaveProperty("cpfEncrypted"); + expect(publicUser).not.toHaveProperty("cpfHash"); + expect(publicUser).not.toHaveProperty("technologiesEncrypted"); + expect(publicUser).not.toHaveProperty("technologyExperiencesEncrypted"); + expect(publicUser).not.toHaveProperty("levelEncrypted"); + }); + it("falls back to plain technologies for invalid encrypted technologies", () => { const publicUser = toPublicUser( baseUser({ diff --git a/backend/tests/unit/services/server.test.ts b/backend/tests/unit/services/server.test.ts index 948b531e..7d985ebc 100644 --- a/backend/tests/unit/services/server.test.ts +++ b/backend/tests/unit/services/server.test.ts @@ -60,14 +60,22 @@ describe("server entry", () => { }); }); - it("inicializa app e chama listen", async () => { - await importServerEntry(); - - expect(mocks.createJobsApiApp).toHaveBeenCalledTimes(1); - expect(mocks.set).toHaveBeenCalledWith("trust proxy", 1); - expect(mocks.listen).toHaveBeenCalled(); - expect(mocks.listen.mock.calls[0][0]).toBe(3100); - }); + it( + "inicializa app e chama listen", + async () => { + await importServerEntry(); + + expect(mocks.createJobsApiApp).toHaveBeenCalledTimes(1); + expect(mocks.set).toHaveBeenCalledWith("trust proxy", 1); + expect(mocks.listen).toHaveBeenCalled(); + expect(mocks.listen.mock.calls[0][0]).toBe(3100); + }, + // Primeiro teste do arquivo: paga o custo do vi.resetModules() + reimport + // completo de server.ts (conexões reais de Valkey/ioredis no boot). Sob a + // suíte inteira (600+ testes concorrentes) isso passa de 5s por contenção + // de CPU, mesmo passando sempre isolado — não é lógica quebrada, é timing. + 15000, + ); it("usa porta padrão quando PORT não definido", async () => { delete process.env.PORT; diff --git a/backend/tests/unit/swagger.test.ts b/backend/tests/unit/swagger.test.ts new file mode 100644 index 00000000..eee68307 --- /dev/null +++ b/backend/tests/unit/swagger.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import swaggerSpec from "../../src/swagger"; + +type SwaggerSpec = { + servers?: Array<{ url: string }>; + paths?: Record>; + components?: { schemas?: Record; responses?: Record }; +}; + +describe("swagger", () => { + it("documenta a API v1 e os endpoints principais", () => { + const spec = swaggerSpec as SwaggerSpec; + + expect(spec.servers).toContainEqual( + expect.objectContaining({ url: "/api/v1" }), + ); + expect(spec.paths).toEqual( + expect.objectContaining({ + "/auth/login": expect.any(Object), + "/users/profile": expect.any(Object), + "/jobs/search": expect.any(Object), + "/saved-jobs": expect.any(Object), + "/notifications": expect.any(Object), + "/admin/users": expect.any(Object), + }), + ); + }); + + it("descreve contratos, exemplos e respostas para os endpoints principais", () => { + const spec = swaggerSpec as SwaggerSpec; + + expect(spec.paths?.["/notifications"]?.get.responses[200].content[ + "application/json" + ].schema).toEqual({ $ref: "#/components/schemas/NotificationListResponse" }); + expect(spec.paths?.["/saved-jobs/{id}"]).toEqual( + expect.objectContaining({ get: expect.any(Object), patch: expect.any(Object), delete: expect.any(Object) }), + ); + expect(spec.paths?.["/notifications/read-all"]?.patch.responses).toEqual( + expect.objectContaining({ 400: { $ref: "#/components/responses/BadRequest" }, 401: { $ref: "#/components/responses/Unauthorized" } }), + ); + expect(spec.paths?.["/users/preferences"]).toEqual( + expect.objectContaining({ get: expect.any(Object), post: expect.any(Object), patch: expect.any(Object) }), + ); + expect(spec.paths?.["/admin/users/{id}"]).toEqual( + expect.objectContaining({ get: expect.any(Object), delete: expect.any(Object) }), + ); + expect(spec.components?.schemas).toEqual( + expect.objectContaining({ + RegisterRequest: expect.any(Object), + ProfileUpdateRequest: expect.any(Object), + SavedJobRequest: expect.any(Object), + NotificationListResponse: expect.any(Object), + }), + ); + expect(spec.components?.responses).toEqual( + expect.objectContaining({ + BadRequest: expect.any(Object), + Unauthorized: expect.any(Object), + Forbidden: expect.any(Object), + NotFound: expect.any(Object), + }), + ); + expect(spec.paths).not.toHaveProperty("/api/keywords"); + }); +}); diff --git a/contribuition.md b/contribuition.md index 7907b9de..3ec4a8fd 100644 --- a/contribuition.md +++ b/contribuition.md @@ -77,6 +77,12 @@ Exemplos: - fix(backend): corrigir metodo da rota jobs search - docs(repo): atualizar guia de testes +Quando o commit estiver ligado a um card do Linear, inclua o identificador no fim da descrição (ver [GUIA-LINEAR-GITHUB.md](GUIA-LINEAR-GITHUB.md)): + +- docs: cria guia de uso do Linear e integracao com GitHub (PAV-93) + +Essa convenção é validada automaticamente pelo hook `commit-msg` (Husky + commitlint, `commitlint.config.cjs`) a cada commit — não é algo para lembrar manualmente. + ## 5. Checklist antes do commit Executar na raiz: @@ -98,14 +104,18 @@ npm run db:migrate O projeto esta preparado para usar Husky com os hooks: -- pre-commit: lint frontend + teste backend -- pre-push: build frontend +- pre-commit: `lint-staged` (roda eslint apenas nos arquivos alterados de `frontend/src`) +- commit-msg: valida a mensagem do commit com `commitlint` (`commitlint.config.cjs`, ver seção 4) +- pre-push: `npm run validate` (teste do backend + lint do frontend + build do frontend) Arquivos de hook: - .husky/pre-commit +- .husky/commit-msg - .husky/pre-push +> **Nota:** o Git não tem um hook que dispare no `git add`. O equivalente automático é o `pre-commit`: ele roda o `lint-staged` sobre exatamente os arquivos que você deu `git add` e está prestes a commitar, toda vez que você roda `git commit`. Ou seja, "rodar a cada `git add .`" na prática significa "rodar a cada commit sobre o que foi adicionado" — e isso já acontece automaticamente, sem nenhuma ação manual. + ### Instalacao inicial Depois de clonar o repositorio: @@ -118,11 +128,21 @@ O script prepare configura o Husky automaticamente. ### Rodar hooks manualmente (debug) +Não há scripts npm dedicados para isso; rode o próprio arquivo de hook: + ```bash -npm run hook:pre-commit -npm run hook:pre-push +sh .husky/pre-commit +sh .husky/pre-push ``` +### `--no-verify` é proibido + +**Nunca use `git commit --no-verify`, `git push --no-verify` ou qualquer flag equivalente para pular os hooks.** Eles existem justamente para pegar problemas antes de chegarem ao PR (lint, testes, build, formato da mensagem de commit). + +Isso não é só uma regra de conduta: como o git não permite bloquear tecnicamente o uso de `--no-verify` no lado do desenvolvedor, o próprio CI (`.github/workflows/ci.yml`, step "Validar mensagens de commit") revalida a mensagem de **todos os commits do PR** com o mesmo `commitlint.config.cjs`. Ou seja, pular o hook local com `--no-verify` só adia o erro — o PR não passa no CI mesmo assim. + +Se um hook falhar, corrija o problema (lint, teste, mensagem de commit) em vez de pular a verificação. + ## 7. Regras de Pull Request Toda PR deve conter: diff --git a/docker-compose.migrate.yml b/docker-compose.migrate.yml index 45698e6b..ad48a4e3 100644 --- a/docker-compose.migrate.yml +++ b/docker-compose.migrate.yml @@ -10,7 +10,7 @@ services: - ./backend/.env environment: DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - command: sh -c "npm run db:migrate && npm run security:backfill-user-pii -- --write" + command: sh -c "npm run db:migrate && npm run db:seed && npm run security:backfill-user-pii -- --write" depends_on: postgres: condition: service_healthy @@ -31,4 +31,4 @@ services: networks: vagas-net: - external: true + external: true \ No newline at end of file diff --git a/front_admin/README.md b/front_admin/README.md index 8908cef1..61169441 100644 --- a/front_admin/README.md +++ b/front_admin/README.md @@ -71,7 +71,7 @@ CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174 ## Rotas principais - `/login`: autenticação do painel. -- `/`: dashboard administrativo. +- `/dashboard`: dashboard administrativo (a rota `/` redireciona automaticamente para `/dashboard`). - `/users`: gestão de usuários. - `/permissions`: permissões. - `/scrapers`: operação de scrapers. diff --git a/front_admin/nginx.conf b/front_admin/nginx.conf index 67e4472b..b5892a99 100644 --- a/front_admin/nginx.conf +++ b/front_admin/nginx.conf @@ -4,6 +4,12 @@ server { root /usr/share/nginx/html; index index.html; + add_header Content-Security-Policy "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: https:; connect-src 'self' http://localhost:3001 https://api.candidate.app.br https://jobsglobalscraper.ddns.net" always; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + location / { try_files $uri $uri/ /index.html; } diff --git a/frontend/ARCHITECTURE.md b/frontend/ARCHITECTURE.md index 56ef6dde..5bbecec0 100644 --- a/frontend/ARCHITECTURE.md +++ b/frontend/ARCHITECTURE.md @@ -11,6 +11,8 @@ O frontend é organizado por domínios de negócio, não por tipos técnicos de - `src/domains//presentation`: páginas e componentes pertencentes ao domínio. - `src/shared`: primitivas de UI, assets, hooks e utilitários técnicos reutilizáveis. +> Exceção: o domínio `new_dashboard` ainda não segue integralmente essas 4 camadas. Hoje ele usa uma organização própria (`components/`, `context/`, `hooks/`, `infrastructure/`, `types/`, `utils/`, `layout.tsx`, `page.tsx`), sem pastas `domain/`, `application/` ou `presentation/` separadas. `auth`, `jobs` e `marketing` seguem o padrão de 4 camadas descrito acima. + ## Domínios - `auth`: estado de sessão, acesso à API de credenciais/OAuth e telas de login, registro e callback. diff --git a/frontend/index.html b/frontend/index.html index 12ad152d..eacb24c7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,35 +5,7 @@ - + <Cand!Date!> diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 67e4472b..96e9032a 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,12 @@ server { root /usr/share/nginx/html; index index.html; + add_header Content-Security-Policy "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' http://localhost:3001 https://api.github.com https://api.candidate.app.br https://jobsglobalscraper.ddns.net" always; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + location / { try_files $uri $uri/ /index.html; } diff --git a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx index 873021ba..95c3efe3 100644 --- a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx +++ b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx @@ -370,6 +370,7 @@ export default function NewDashboardPage() { changeJobNotesLocally(jobId, notes); }; + const handleAddJob = async (newJob: NewJob) => { try { await addTrackedJob(newJob); diff --git a/frontend/src/domains/new_dashboard/components/jobs/ApplicationNotesSection.tsx b/frontend/src/domains/new_dashboard/components/jobs/ApplicationNotesSection.tsx new file mode 100644 index 00000000..61a63137 --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/jobs/ApplicationNotesSection.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { + type ApplicationNote, + createDashboardApplicationNote, + deleteDashboardApplicationNote, + getDashboardApplicationNotes, + updateDashboardApplicationNote, +} from "../../infrastructure/dashboardJobsApi"; + +export function ApplicationNotesSection({ savedJobId }: { savedJobId: string }) { + const [notes, setNotes] = useState([]); + const [content, setContent] = useState(""); + const [editing, setEditing] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + let active = true; + void getDashboardApplicationNotes(savedJobId) + .then((items) => active && setNotes(items)) + .catch(() => active && setError(true)) + .finally(() => active && setLoading(false)); + return () => { active = false; }; + }, [savedJobId]); + + async function save() { + const value = content.trim(); + if (!value) return; + try { + if (editing) { + const updated = await updateDashboardApplicationNote(savedJobId, editing.id, value); + setNotes((items) => items.map((item) => item.id === updated.id ? updated : item)); + } else { + const created = await createDashboardApplicationNote(savedJobId, value); + setNotes((items) => [...items, created]); + } + setContent(""); + setEditing(null); + } catch { setError(true); } + } + + async function remove(noteId: string) { + try { + await deleteDashboardApplicationNote(savedJobId, noteId); + setNotes((items) => items.filter((item) => item.id !== noteId)); + } catch { setError(true); } + } + + return
+

Notas privadas

+ {loading ?

Carregando notas...

: null} + {error ?

Não foi possível atualizar as notas.

: null} + {!loading && notes.length === 0 ?

Nenhuma nota adicionada.

: null} + {notes.map((note) =>
+

{note.content}

+
+ + +
+
)} +