diff --git a/.env.example b/.env.example index f337aad..c7cf023 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,14 @@ 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. +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 ADZUNA_APP_ID= ADZUNA_APP_KEY= diff --git a/.specs/STATE.md b/.specs/STATE.md new file mode 100644 index 0000000..5c733b1 --- /dev/null +++ b/.specs/STATE.md @@ -0,0 +1,20 @@ +# Project State + +## Decisions + +Active project-level architectural decisions (AD-NNN). Each design must conform or explicitly supersede. + +| ID | Decision | Status | Source | +| --- | --- | --- | --- | +| AD-001 | E-mails transacionais são enviados via módulo centralizado `src/modules/email` — nenhum outro módulo chama provedor de e-mail diretamente. | active | PAV-76 | +| AD-002 | Envio de e-mail é assíncrono via fila BullMQ sobre Valkey (conexão ioredis dedicada). Callers apenas enfileiram; nunca bloqueiam nem falham por causa de e-mail. | active | PAV-76 | +| AD-003 | Provedor de e-mail acessado por trás da interface `MailProvider`. Implementação inicial: Resend. Trocar de provedor não altera código chamador. | active | PAV-76 | +| AD-004 | Templates de e-mail são componentes react-email tipados, renderizados server-side para HTML. Versões `react`/`react-dom`/`@types/react` fixadas em 19.x para casar com o frontend. | active | PAV-76 | + +## 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. diff --git a/.specs/features/email-module/context.md b/.specs/features/email-module/context.md new file mode 100644 index 0000000..9553570 --- /dev/null +++ b/.specs/features/email-module/context.md @@ -0,0 +1,17 @@ +# Email Module — User Decisions (context) + +Captured during the discuss phase of PAV-76. These constrain the design. + +| Área | Decisão do usuário | Detalhe | +| --- | --- | --- | +| Provedor | **Resend** como primeira implementação | Atrás de interface `MailProvider` para troca futura barata. | +| Estratégia de envio | **Fila de jobs (BullMQ)** | Usuário quer sistema de jobs. Projeto usava Redis; hoje usa Valkey. Verificado: BullMQ é 100% compatível com Valkey via ioredis. | +| Templates | **react-email** | ⚠️ Fixar versões de `react`/`react-dom`/`@types/react` em **19.x** para casar com o frontend (React 19.2.7) e não quebrar o build. | +| Persistência | **Somente logar** (sem tabela) | Sem `email_logs` no MVP. Auditoria em DB fica como evolução futura. | + +## Verificações técnicas (Knowledge Verification Chain) + +- **BullMQ + Valkey:** compatível em produção (Valkey = fork Redis 7.2.4). BullMQ usa adapter ioredis; backend hoje usa `node-redis` só p/ cache (`src/lib/cache.ts`) → BullMQ adiciona conexão ioredis dedicada ao mesmo `VALKEY_URL` com `maxRetriesPerRequest: null`. +- **react-email + React 19:** `render()` async funciona 100% com React 19; `@react-email/render` 2.1.0 suporta `react-dom ^19`. Bug conhecido com `@types/react@18` → fixar `@types/react@19`. Requer `jsx: react-jsx` no tsconfig do backend. +- **Frontend React:** `^19.2.7` (frontend/package.json). +- **Backend engine:** Node >= 22. diff --git a/.specs/features/email-module/design.md b/.specs/features/email-module/design.md new file mode 100644 index 0000000..b3ab976 --- /dev/null +++ b/.specs/features/email-module/design.md @@ -0,0 +1,193 @@ +# Email Module — Design + +**Spec**: `.specs/features/email-module/spec.md` +**Context**: `.specs/features/email-module/context.md` +**Status**: Draft — aguardando aprovação + +--- + +## Architecture Overview + +Módulo centralizado `src/modules/email` seguindo o padrão do backend (`Service` + arquivos coesos). Envio **assíncrono** via fila BullMQ sobre Valkey. Callers só enfileiram; um worker in-process renderiza o template (react-email) e despacha pelo `MailProvider` (Resend). Provider e templates são abstraídos e versionados. + +```mermaid +graph TD + Caller["Caller (AuthService.register, ...)"] -->|"send({template,to,data})"| Svc[EmailService] + Svc -->|valida to + template| Reg[TemplateRegistry] + Svc -->|enqueue| Q["BullMQ Queue 'email' (Valkey via ioredis)"] + Q --> W[EmailWorker in-process] + W -->|render| Reg + W -->|"send({to,subject,html})"| P{MailProvider} + P -->|env presente| Resend[ResendProvider -> Resend API] + P -->|env ausente| Noop[NoopProvider -> log only] + W -->|retry backoff / log final| Log[logger] +``` + +**Ciclo de vida:** a fila e o worker sobem em `server.ts` no boot e param no graceful shutdown (junto com `closeCache`). Enqueue é não-bloqueante; falha de enqueue é logada e engolida pelo caller. + +--- + +## Code Reuse Analysis + +### Existing Components to Leverage + +| Component | Location | How to Use | +| --- | --- | --- | +| Padrão de módulo (Service coeso) | `src/modules/notifications/*` | Espelhar estrutura e estilo. | +| `AppError` | `src/lib/errors.ts` | `AppError.validation` p/ `to`/template inválido no enqueue. | +| Logger | `src/logger.ts` | `logInfo/logWarn/logError` em enqueue, worker e providers. | +| Config pattern | `src/config.ts` | Adicionar envs de e-mail via `getConfig()` + parsers. | +| Valkey URL | `process.env.VALKEY_URL` (usado em `src/lib/cache.ts`) | Reusar a mesma instância Valkey p/ a conexão ioredis do BullMQ. | +| Registro de rotas / boot | `src/app.ts`, `src/server.ts` | Iniciar/parar worker no lifecycle do servidor. | +| Padrão de teste | `tests/unit/modules/*`, `tests/integration/*` | Vitest + mocks (hoisted), threshold 80%. | +| Fluxo de registro | `src/modules/auth/**` (`CredentialsService.register`) | Gatilho de boas-vindas no cadastro por e-mail/senha. | +| Fluxo de login social | `src/modules/auth/auth.service.ts` (`handleCallback`) + `src/modules/users/functions/findOrCreateUser.ts` | Gatilho de boas-vindas no primeiro login social; `findOrCreateUser` retorna `isNewUser` para disparar só em conta recém-criada. | + +### Integration Points + +| System | Integration Method | +| --- | --- | +| Valkey | Nova conexão `ioredis` (BullMQ exige) ao mesmo `VALKEY_URL`, prefixo de chaves próprio (`bull:email`). Não interfere no `node-redis` do cache. | +| Auth (registro) | `CredentialsService.register` chama `emailService.sendWelcome(...)` dentro de try/catch que só loga. | +| Auth (login social) | `AuthService.handleCallback` chama `emailService.sendWelcome(...)` dentro de try/catch (só loga) quando `findOrCreateUser` retorna `isNewUser: true`. | +| Resend | HTTP via SDK `resend` usando `EMAIL_API_KEY`. | + +--- + +## Components + +### EmailService +- **Purpose**: API interna única de e-mail. Valida e enfileira jobs. +- **Location**: `src/modules/email/email.service.ts` +- **Interfaces**: + - `send(input: { template: TemplateName; to: string; data: TemplateData }): Promise` — valida `to` (formato) e `template` (existe no registry); enfileira job; nunca aguarda entrega. Erros de validação lançam `AppError.validation`; falha de enqueue é logada e **não** propagada. + - `sendWelcome(user: { email: string; name: string }): Promise` — açúcar sobre `send` com template `welcome`. +- **Dependencies**: `EmailQueue`, `TemplateRegistry`, logger. +- **Reuses**: `AppError`, logger. + +### EmailQueue +- **Purpose**: Encapsula a fila BullMQ e sua conexão Valkey. +- **Location**: `src/modules/email/email.queue.ts` +- **Interfaces**: + - `getEmailQueue(): Queue` — singleton lazy da `Queue`. + - `enqueueEmail(data: EmailJobData): Promise` — adiciona job com opções de retry/backoff. + - `closeEmailQueue(): Promise` — fecha conexão no shutdown. +- **Dependencies**: `bullmq`, `ioredis`, `VALKEY_URL`. +- **Reuses**: padrão singleton de `src/lib/cache.ts`. +- **Config da conexão**: `new IORedis(VALKEY_URL, { maxRetriesPerRequest: null })` (requisito do BullMQ). + +### EmailWorker +- **Purpose**: Processa jobs: renderiza template → despacha via provider. +- **Location**: `src/modules/email/email.worker.ts` +- **Interfaces**: + - `startEmailWorker(): Worker` — cria e retorna o `Worker` (chamado no boot). + - `stopEmailWorker(): Promise` — fecha o worker no shutdown. + - processor: `async (job) => { html = registry.render(job.data); await provider.send({ to, subject, html }); }` +- **Dependencies**: `TemplateRegistry`, `MailProvider` (via factory), logger. +- **Retry**: `attempts: 3`, `backoff: { type: 'exponential', delay: 2000 }` (config). Falha final logada em `worker.on('failed')`. + +### MailProvider (interface) + factory +- **Purpose**: Contrato de envio, desacoplando o provedor concreto. +- **Location**: `src/modules/email/providers/mail-provider.ts` (interface + `getMailProvider()` factory) +- **Interfaces**: + - `interface MailProvider { send(msg: { to: string; subject: string; html: string; replyTo?: string }): Promise }` + - `getMailProvider(): MailProvider` — retorna `ResendProvider` se `EMAIL_API_KEY` presente, senão `NoopProvider`. +- **Reuses**: config, logger. + +### ResendProvider +- **Location**: `src/modules/email/providers/resend.provider.ts` +- **Interfaces**: `send(...)` via SDK `resend` (`resend.emails.send({ from, to, subject, html, replyTo })`). Lança em falha (BullMQ faz retry). +- **Dependencies**: `resend`, config (`EMAIL_API_KEY`, `EMAIL_FROM_ADDRESS`, `EMAIL_FROM_NAME`). + +### NoopProvider +- **Location**: `src/modules/email/providers/noop.provider.ts` +- **Interfaces**: `send(...)` → `logWarn("Email disabled: no provider configured", {...})`. Não lança (EMAIL-09). + +### TemplateRegistry + templates +- **Purpose**: Mapear `TemplateName → { subject, render(data) }`, validando nomes e renderizando react-email para HTML. +- **Location**: `src/modules/email/templates/registry.ts`, `src/modules/email/templates/welcome.tsx`, `src/modules/email/templates/BaseLayout.tsx` +- **Interfaces**: + - `type TemplateName = 'welcome'` + - `isTemplate(name: string): name is TemplateName` + - `renderTemplate(name, data): Promise<{ subject: string; html: string }>` — usa `render()` (async) do `@react-email/render`. +- **Dependencies**: `react`, `react-dom`, `@react-email/render`. + +### Rotas / Boot +- **Location**: `src/server.ts` — chamar `startEmailWorker()` no boot e `stopEmailWorker()`/`closeEmailQueue()` no shutdown. +- Sem rota HTTP nova no MVP (contato fora de escopo). O módulo é consumido internamente. + +--- + +## Data Models + +### EmailJobData (payload do job BullMQ) +```typescript +interface EmailJobData { + template: TemplateName; // 'welcome' + to: string; // e-mail destino (validado no enqueue) + data: Record; // props do template (ex.: { name }) +} +``` +**Relationships**: efêmero (fila). Sem persistência em DB (decisão: só log). + +### Config additions (`src/config.ts`) +```typescript +interface AppConfig { + // ... + emailApiKey: string; // EMAIL_API_KEY (vazio => NoopProvider) + emailFromAddress: string; // EMAIL_FROM_ADDRESS + emailFromName: string; // EMAIL_FROM_NAME + emailQueueAttempts: number; // EMAIL_QUEUE_ATTEMPTS (default 3) +} +``` +`.env.example` ganha: `EMAIL_API_KEY=`, `EMAIL_FROM_ADDRESS=`, `EMAIL_FROM_NAME=`, `EMAIL_QUEUE_ATTEMPTS=3`. + +**URL do CTA:** o botão "Acessar plataforma" do template `welcome` reusa a env **`FRONTEND_URL`** (já existente em `.env.example` e usada em `auth.controller.ts`). Nenhuma env de URL nova é criada. O `welcome.tsx` recebe `appUrl` como prop, injetada pelo `EmailService.sendWelcome` a partir de `config.frontendUrl`. + +--- + +## Error Handling Strategy + +| Error Scenario | Handling | Impacto | +| --- | --- | --- | +| `to` inválido / template desconhecido no enqueue | `AppError.validation` lançado ao caller | Caller trata (é bug de programação interno). EMAIL-05. | +| Falha ao enfileirar (Valkey down) | log de erro, exceção engolida em `send` | Fluxo de negócio (registro) segue normal. EMAIL-10, EMAIL-13→10. | +| Falha do provider no worker | throw → BullMQ retry (3x, backoff exp.) | Transparente; e-mail eventualmente entregue ou logado no fracasso final. EMAIL-03. | +| Fracasso final após retries | `worker.on('failed')` → `logError` | Nenhum; e-mail perdido é logado (sem DB no MVP). EMAIL-03. | +| Env de e-mail ausente | `getMailProvider()` retorna `NoopProvider` (loga) | API sobe e opera; e-mails viram no-op logado. EMAIL-09. | + +--- + +## Risks & Concerns + +| Concern | Location | Impact | Mitigation | +| --- | --- | --- | --- | +| JSX/React no backend (hoje sem react/tsconfig JSX) | `backend/tsconfig*.json` | Build quebra sem `jsx: react-jsx` e sem deps React | Task dedicada: instalar `react@19`,`react-dom@19`,`@types/react@19` (casar frontend) + ativar JSX no tsconfig. Verificado como compatível. | +| Conflito de versões React 19 x `@types/react@18` | `backend/package.json` | Erros de tipo no `render()` | Fixar `@types/react@19` (bug conhecido documentado). | +| Nova conexão ioredis ao Valkey (além do node-redis) | `email.queue.ts` | Duas libs de client no mesmo processo | Aceitável: BullMQ exige ioredis; escopo isolado ao módulo, prefixo `bull:email`. Sem impacto no cache existente. | +| Worker in-process compete por CPU/event-loop com a API | `server.ts` | Em volume alto, render+envio pode pesar | Volume MVP é baixo (boas-vindas). Concurrency baixa no worker; extraível p/ processo separado depois sem mudar a fila (AD-002). | +| Perda de e-mail no fracasso final (sem DB) | worker | E-mail não entregue só vira log | Aceito no MVP (decisão do usuário). `email_logs` é evolução futura registrada em Out of Scope. | +| Testar código que toca Valkey/BullMQ real | testes | Testes lentos/flaky se conectarem de verdade | Mockar `email.queue` e `MailProvider`; testar `EmailService`/worker com fakes em memória (padrão hoisted já usado no repo). | + +> Concern flag: `AuthService.register` precisa de um try/catch ao redor do `sendWelcome` — hoje não existe; a task de integração adiciona sem alterar o contrato de retorno do registro. + +--- + +## Tech Decisions + +| Decisão | Escolha | Rationale | +| --- | --- | --- | +| Transporte | BullMQ sobre Valkey (ioredis) | Fila durável com retry/backoff nativo; base p/ futuros alertas de vagas. AD-002. | +| Provider | Resend atrás de `MailProvider` | Decisão do usuário; troca barata. AD-003. | +| Templates | react-email, React fixo em 19.x | Casa com frontend; evita quebra de build. AD-004. | +| Worker | In-process (boot em server.ts) | Simples p/ MVP; extraível depois. | +| Sem provider configurado | NoopProvider (no-op + log) | Dev local sobe sem credenciais. | +| Persistência | Nenhuma (só log) | Decisão do usuário; `email_logs` futuro. | + +> Decisões de nível de projeto já registradas em `.specs/STATE.md` como AD-001..AD-004. + +--- + +## Open decisions + +Nenhuma — todas confirmadas com o usuário. Pronto para a fase de Tasks. diff --git a/.specs/features/email-module/spec.md b/.specs/features/email-module/spec.md new file mode 100644 index 0000000..b20eaf7 --- /dev/null +++ b/.specs/features/email-module/spec.md @@ -0,0 +1,125 @@ +# Módulo de E-mail Centralizado — Specification + +**Task:** PAV-76 · **Team:** Painel Vagas + +## Problem Statement + +A plataforma passou a ter domínio próprio e precisa enviar e-mails transacionais (boas-vindas, contato/suporte e, futuramente, recuperação de senha, confirmação de e-mail, alertas de vagas). Hoje não existe nenhum mecanismo de envio. Sem um ponto único, cada funcionalidade implementaria seu próprio envio — duplicando código e dificultando manutenção e troca de provedor. + +## Goals + +- [ ] Expor uma **API interna única** de envio de e-mail, consumível por qualquer módulo do backend. +- [ ] Enviar de forma **assíncrona e resiliente** (fila com retry), sem que a falha de e-mail derrube o request que o originou. +- [ ] Separar **lógica de envio** (provider) de **templates** (react-email), ambos versionados no projeto. +- [ ] Entregar o fluxo funcional de **boas-vindas** como primeiro consumidor real, disparado tanto no **registro por e-mail/senha** quanto no **primeiro login social** (Google, GitHub, LinkedIn) — apenas para contas recém-criadas. +- [ ] Deixar o provedor **trocável** via configuração, sem alterar código chamador. +- [ ] Documentar o uso do módulo no repositório. + +## Out of Scope + +| Feature | Motivo | +| --- | --- | +| **Contato/Suporte** (endpoint + e-mail) | Removido do escopo pelo usuário: o link de "Ajuda & Suporte" foi retirado do frontend em outro PR e só voltará quando a seção for decidida. A arquitetura mantém a API interna genérica, então plugar esse fluxo depois é só um novo template + caller. | +| Recuperação de senha, confirmação de e-mail, troca de e-mail | Evolução futura (PAV-8, PAV-36); o módulo apenas prepara terreno. | +| Alertas de vagas / notificações em lote | Evolução futura; exige fan-out e agregação próprios. | +| Persistência de logs de envio em DB (`email_logs`) | Decisão do usuário: MVP só loga. Auditoria em DB fica para depois. | +| UI de administração de e-mails / dashboard | Não solicitado. | +| Preferências de opt-in/opt-out por usuário | `userPreferences.emailNotifications` já existe mas seu enforcement não é escopo desta task. | +| Segundo provedor (SES/SMTP) implementado | Só a interface é entregue; implementação concreta apenas Resend. | + +--- + +## Assumptions & Open Questions + +| Assumption / decisão | Escolha | Racional | Confirmado? | +| --- | --- | --- | --- | +| Provedor inicial | Resend | Decisão do usuário; API simples, integra com domínio próprio. | y | +| Transporte assíncrono | Fila BullMQ sobre Valkey | Decisão do usuário (quer jobs); BullMQ compatível com Valkey (verificado). | y | +| Templates | react-email, versões React fixadas em 19.x | Decisão do usuário; casa com frontend, evita quebra de build. | y | +| Persistência de envios | Somente log (sem tabela) | Decisão do usuário. | y | +| Deployment do worker BullMQ | **In-process** (inicia junto do servidor Express) | Volume baixo no MVP; simples. Extraível p/ processo separado depois sem mudar a fila. | y | +| Comportamento se env de e-mail ausente | Log de aviso + envio vira no-op (não derruba boot) | Ambiente local sem chave não deve quebrar a API. | y | +| Fluxo de contato/suporte | **Fora do escopo** | Seção removida do produto; volta em task futura. | y | + +**Open questions:** none — todas resolvidas ou registradas acima. + +--- + +## User Stories + +### P1: API interna de envio + entrega assíncrona ⭐ MVP + +**User Story**: Como desenvolvedor de qualquer módulo do backend, quero chamar uma única API interna (`emailService.send(...)`) para disparar um e-mail, para não implementar envio próprio. + +**Why P1**: É o núcleo do módulo — sem isso nada mais existe. Vertical slice: enfileira → worker renderiza template → provider envia. + +**Acceptance Criteria**: +1. WHEN um módulo chama `emailService.send({ template, to, data })` com dados válidos THEN o sistema SHALL enfileirar um job de e-mail e retornar sem aguardar a entrega. +2. WHEN um job de e-mail é processado pelo worker THEN o sistema SHALL renderizar o template HTML indicado e despachá-lo através do provedor configurado (Resend). +3. WHEN o despacho pelo provedor falha THEN o worker SHALL re-tentar com backoff até o limite configurado e, no fracasso final, registrar erro em log — sem lançar exceção para o caller original. +4. WHEN o provedor é trocado via configuração THEN nenhum código chamador SHALL precisar de alteração (contrato via interface `MailProvider`). +5. WHEN `to` é um e-mail inválido OU `template` é desconhecido THEN o sistema SHALL rejeitar no momento do enqueue com erro de validação, antes de criar o job. + +**Independent Test**: Chamar `emailService.send` com um provider fake em memória e asseverar que o job foi enfileirado, o template renderizou HTML esperado e o provider recebeu `{ to, subject, html }`; simular falha do provider e asseverar retry + log. + +--- + +### P1: E-mail de boas-vindas no registro e no primeiro login social ⭐ MVP + +**User Story**: Como novo usuário, quero receber um e-mail de boas-vindas quando minha conta é criada — seja por cadastro com e-mail/senha ou pelo primeiro login social (Google, GitHub, LinkedIn) —, para confirmar que minha conta foi criada. + +**Why P1**: Critério de aceite explícito da task (template funcional de boas-vindas) e primeiro consumidor real da API interna. Como a maioria dos cadastros ocorre via login social, o gatilho precisa cobrir esse caminho além do registro por credenciais. + +**Acceptance Criteria**: +1. WHEN um usuário completa o registro por e-mail/senha com sucesso THEN o sistema SHALL enfileirar um e-mail de boas-vindas para o endereço do usuário. +2. WHEN um usuário faz login social (Google, GitHub ou LinkedIn) E uma conta é criada pela primeira vez THEN o sistema SHALL enfileirar um e-mail de boas-vindas para o endereço do usuário. +3. WHEN um usuário faz login social E a conta já existe (relogin, ou vínculo de um novo provider a um usuário que já se cadastrou antes) THEN o sistema SHALL NÃO enfileirar e-mail de boas-vindas. +4. WHEN o e-mail de boas-vindas é renderizado THEN ele SHALL conter o nome do usuário e o HTML do template de boas-vindas. +5. WHEN o e-mail de boas-vindas é renderizado THEN ele SHALL incluir um botão "Acessar plataforma" cujo link aponta para `FRONTEND_URL` (env já existente, reusada). +6. WHEN o enfileiramento do e-mail de boas-vindas falha THEN o fluxo de origem (registro OU login social) SHALL concluir normalmente mesmo assim (falha de e-mail é logada, não propagada). + +**Independent Test**: (a) Executar `CredentialsService.register` com fila mockada e asseverar que um job "welcome" foi enfileirado com o `to`/nome corretos. (b) Executar `AuthService.handleCallback` para um perfil OAuth sem usuário correspondente e asseverar que boas-vindas é enfileirado; repetir para um usuário já existente (achado por provider ou por e-mail) e asseverar que **não** é enfileirado. (c) Em ambos os fluxos, forçar erro no envio e asseverar que a operação de origem ainda conclui com sucesso. + +--- + +## Edge Cases + +- WHEN as variáveis de ambiente de e-mail (ex: `EMAIL_API_KEY`) estão ausentes THEN o sistema SHALL logar aviso e tratar o envio como no-op, sem derrubar o boot da API. +- WHEN o Valkey está indisponível no momento do enqueue THEN o `emailService.send` SHALL logar erro e não propagar exceção que quebre o fluxo de negócio chamador. +- WHEN dois registros do mesmo usuário disparam boas-vindas THEN cada envio é um job independente (sem dedup no MVP — aceitável). +- WHEN um usuário que já se cadastrou por e-mail/senha faz login social pela primeira vez THEN o provider é vinculado à conta existente e boas-vindas NÃO é reenviado (usuário não é considerado novo). +- WHEN um usuário faz relogin social (conta já vinculada ao provider) THEN nenhum e-mail de boas-vindas é enfileirado. +- WHEN o template referenciado não existe no registry de templates THEN o enqueue SHALL falhar com erro de validação (não gerar job órfão). + +--- + +## Requirement Traceability + +| Requirement ID | Story | Fase | Status | +| --- | --- | --- | --- | +| EMAIL-01 | P1 API interna — enqueue não-bloqueante | Design | Pending | +| EMAIL-02 | P1 API interna — worker renderiza + envia via provider | Design | Pending | +| EMAIL-03 | P1 API interna — retry com backoff + log no fracasso | Design | Pending | +| EMAIL-04 | P1 API interna — provider trocável via interface | Design | Pending | +| EMAIL-05 | P1 API interna — validação de `to`/`template` no enqueue | Design | Pending | +| EMAIL-06 | P1 Boas-vindas — enfileira no registro por e-mail/senha | Design | Pending | +| EMAIL-07 | P1 Boas-vindas — template com nome/HTML | Design | Pending | +| EMAIL-08 | P1 Boas-vindas — falha não quebra fluxo de origem | Design | Pending | +| EMAIL-09 | Edge — env ausente vira no-op sem quebrar boot | Design | Pending | +| EMAIL-10 | Edge — Valkey down no enqueue não propaga | Design | Pending | +| EMAIL-11 | Doc — documentação de uso no repositório | Design | Pending | +| EMAIL-12 | P1 Boas-vindas — enfileira no primeiro login social (Google/GitHub/LinkedIn, usuário novo) | Design | Pending | +| EMAIL-13 | P1 Boas-vindas — não reenvia em relogin/vínculo de provider a usuário existente | Design | Pending | + +**ID format:** `EMAIL-NN` · **Status:** Pending → In Design → In Tasks → Implementing → Verified +**Coverage:** 13 total, 0 mapeados a tasks (Tasks ainda não iniciada). + +--- + +## Success Criteria + +- [ ] Um módulo consegue enviar e-mail com uma única chamada, sem conhecer o provedor. +- [ ] E-mail de boas-vindas chega em ambiente local e produção (template HTML funcional). +- [ ] Falha de provedor/fila nunca derruba o fluxo de negócio chamador. +- [ ] Trocar provider = implementar interface + mudar env, sem tocar callers. +- [ ] Documentação de uso adicionada ao repositório. diff --git a/.specs/features/email-module/tasks.md b/.specs/features/email-module/tasks.md new file mode 100644 index 0000000..3ffb8fc --- /dev/null +++ b/.specs/features/email-module/tasks.md @@ -0,0 +1,426 @@ +# Email Module — Tasks + +## Execution Protocol (MANDATORY — do not skip) + +Implement these tasks with the `tlc-spec-driven` skill: **activate it by name and follow its Execute flow and Critical Rules.** Do not search for skill files by filesystem path. + +**Política de commit (AGENTS.md sobrepõe o skill):** o repositório proíbe commits automáticos ("NUNCA faça commits automaticamente. Sempre pergunte"). Portanto, os workers **implementam + rodam o gate por task, mas NÃO commitam**. Cada task deixa a mudança pronta e verificada; o orchestrator apresenta os commits atômicos propostos (mensagens em **português**) para o usuário aprovar. Um commit por task continua sendo a unidade — só que aplicado após aprovação. + +**If the skill cannot be activated, STOP and tell the user.** + +--- + +**Design**: `.specs/features/email-module/design.md` +**Spec**: `.specs/features/email-module/spec.md` +**Status**: ✅ Done — 11/11 tasks commitadas; Verifier PASS (529 testes, sensor 5/5); ver `validation.md` + +--- + +## Test Coverage Matrix + +> Gerada de codebase + guidelines + spec — confirmar antes do Execute. Guidelines encontradas: `AGENTS.md` (commits/pt-BR), `backend/vitest.config.js` (threshold 80%, `include: src/**/*.ts`), `TESTING.md` (QA manual, não automatiza), padrão de testes em `backend/tests/unit/modules/*` e `backend/tests/integration/routes/*`. + +| Code Layer | Required Test Type | Coverage Expectation | Location Pattern | Run Command | +| --- | --- | --- | --- | --- | +| Service (`EmailService`) | unit | Todas as branches; 1:1 com ACs; cada edge case listado | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Provider (`Resend`, `Noop`, factory) | unit | Caminho-chave de envio + erro + seleção por env | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Queue (`email.queue`) | unit | enqueue com opções de retry; guarda de conexão (mock bullmq/ioredis) | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Worker (`email.worker`) | unit | processor renderiza+envia; falha propaga p/ retry; handler `failed` loga | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Template Registry (`registry.ts`) | unit | render de `welcome` (nome + CTA + subject); template desconhecido rejeitado | `backend/tests/unit/modules/email/*.test.ts` | `cd backend && npm test` | +| Templates (`*.tsx`) | none | Verificados indiretamente via Registry (coverage exclui `.tsx`) | `backend/src/modules/email/templates/*.tsx` | build gate | +| Auth integration (`AuthService.register`) | unit | boas-vindas enfileirado no sucesso; registro conclui mesmo se envio falhar | `backend/tests/unit/modules/auth/*.test.ts` (ou existente) | `cd backend && npm test` | +| Config (`config.ts`) + `.env.example` | none | Sem teste dedicado no repo (padrão config) — build gate | `backend/src/config.ts` | build gate | +| Boot wiring (`server.ts`) | none | Lifecycle wiring — build gate | `backend/src/server.ts` | build gate | +| Deps / tsconfig | none | Toolchain — build gate | `backend/package.json`, `backend/tsconfig.json` | build gate | +| Docs | none | Documentação — sem teste | `BACKEND.md` / `docs/` | — | + +## Gate Check Commands + +> Confirmar antes do Execute. Backend não possui script de build/lint/typecheck; `npm test` (vitest run) roda unit + integração no mesmo runner e é o verificador determinístico. + +| Gate Level | When to Use | Command | +| --- | --- | --- | +| Quick | Após tasks só com unit tests | `cd backend && npm test` | +| Full | Após tasks com integração | `cd backend && npm test` | +| Build | Fim de fase / tasks sem testes (config, deps, wiring, docs) | `cd backend && npm test` | + +> Alvo de cobertura: novos arquivos `src/**/*.ts` devem atingir ~80% localmente (threshold do `vitest.config.js`). `npm run test:coverage --workspace=backend` é o comando de conferência de cobertura, usado pelo Verifier — não como gate por-task (evita falha por cobertura global pré-existente). + +--- + +## Execution Plan + +Fases ordenadas, executadas em sequência; tasks dentro da fase em ordem. + +### Phase 1: Foundation (toolchain + config) + +``` +T1 → T2 +``` + +### Phase 2: Provider layer + +``` +T3 → T4 +``` + +### Phase 3: Templates + +``` +T5 +``` + +### Phase 4: Transport + API interna + +``` +T6 → T7 → T8 +``` + +### Phase 5: Integração + docs + +``` +T9 → T10 → T11 +``` + +--- + +## Task Breakdown + +### T1: Instalar dependências e habilitar JSX no backend + +**What**: Adicionar `bullmq`, `ioredis`, `resend`, `react@19`, `react-dom@19`, `@types/react@19` (dev), `@react-email/render` ao backend e habilitar `"jsx": "react-jsx"` no tsconfig. +**Where**: `backend/package.json`, `backend/tsconfig.json` +**Depends on**: None +**Reuses**: — +**Requirement**: EMAIL-02, EMAIL-04 (habilitadores) + +**Tools**: +- MCP: `context7` (conferir versões/peer deps de `@react-email/render` e `bullmq`) +- Skill: NONE + +**Done when**: +- [ ] `react`/`react-dom` em `^19` e `@types/react` em `^19` (casam com frontend; evita bug `@types/react@18`) +- [ ] `bullmq`, `ioredis`, `resend`, `@react-email/render` instalados +- [ ] `tsconfig.json` com `"jsx": "react-jsx"` +- [ ] Gate passa: `cd backend && npm test` (suíte existente continua verde) + +**Tests**: none +**Gate**: build + +--- + +### T2: Adicionar configuração de e-mail + +**What**: Adicionar campos de e-mail ao `AppConfig`/`getConfig()` e ao `.env.example`: `emailApiKey`, `emailFromAddress`, `emailFromName`, `emailQueueAttempts` (default 3) e reuso de `frontendUrl` (env `FRONTEND_URL` já existente) para o CTA. +**Where**: `backend/src/config.ts`, `.env.example` +**Depends on**: None +**Reuses**: padrão `parseNumber`/`getConfig` em `src/config.ts`; env `FRONTEND_URL` +**Requirement**: EMAIL-04, EMAIL-07, EMAIL-09 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] `getConfig()` retorna os novos campos com defaults seguros (chave vazia ⇒ string vazia) +- [ ] `emailQueueAttempts` parseado como número com default 3 +- [ ] `frontendUrl` exposto a partir de `FRONTEND_URL` +- [ ] `.env.example` documenta `EMAIL_API_KEY`, `EMAIL_FROM_ADDRESS`, `EMAIL_FROM_NAME`, `EMAIL_QUEUE_ATTEMPTS` +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: none +**Gate**: build + +--- + +### T3: Interface MailProvider + NoopProvider + factory + +**What**: Definir `interface MailProvider { send(msg) }`, implementar `NoopProvider` (loga, não envia, não lança) e `getMailProvider()` que retorna Resend quando `EMAIL_API_KEY` presente, senão Noop. +**Where**: `backend/src/modules/email/providers/mail-provider.ts`, `backend/src/modules/email/providers/noop.provider.ts` +**Depends on**: T2 +**Reuses**: `logWarn` (`src/logger.ts`), `getConfig` (`src/config.ts`) +**Requirement**: EMAIL-04, EMAIL-09 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] `getMailProvider()` retorna `NoopProvider` quando `emailApiKey` vazio e `ResendProvider` quando presente +- [ ] `NoopProvider.send` loga aviso e resolve sem lançar +- [ ] Testes unitários cobrem seleção por env + no-op sem throw +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T4: ResendProvider + +**What**: Implementar `ResendProvider.send({to,subject,html,replyTo})` via SDK `resend`, usando `from` = `EMAIL_FROM_NAME `; lançar em falha do SDK (para o BullMQ re-tentar). +**Where**: `backend/src/modules/email/providers/resend.provider.ts` +**Depends on**: T1, T2, T3 +**Reuses**: interface de T3, `getConfig`, `logError` +**Requirement**: EMAIL-02, EMAIL-04 + +**Tools**: +- MCP: `context7` (assinatura de `resend.emails.send`) +- Skill: NONE + +**Done when**: +- [ ] `send` chama o SDK com `{ from, to, subject, html, replyTo? }` mapeados corretamente +- [ ] Erro do SDK é propagado (lançado), não engolido +- [ ] Testes unitários mockam `resend` e asseveram payload + propagação de erro +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T5: Templates react-email + TemplateRegistry + +**What**: Criar `BaseLayout.tsx`, `welcome.tsx` (props `{ name, appUrl }`, com botão "Acessar plataforma" → `appUrl`) e `registry.ts` (`TemplateName`, `isTemplate`, `renderTemplate` async via `@react-email/render`, com `subject` por template). +**Where**: `backend/src/modules/email/templates/BaseLayout.tsx`, `welcome.tsx`, `registry.ts` +**Depends on**: T1 +**Reuses**: `@react-email/render` +**Requirement**: EMAIL-07, EMAIL-05 (template desconhecido) + +**Tools**: +- MCP: `context7` (`render` async do react-email; componentes) +- Skill: NONE + +**Done when**: +- [ ] `renderTemplate('welcome', { name, appUrl })` retorna `{ subject, html }` com HTML contendo o `name` e um link/botão com `href` = `appUrl` +- [ ] `isTemplate('desconhecido')` é `false`; `renderTemplate` com nome inválido rejeita/lança +- [ ] Testes unitários no registry cobrem render de welcome (nome + CTA href + subject) e rejeição de template desconhecido +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit (registry) · templates `.tsx` = none (via registry) +**Gate**: quick + +--- + +### T6: EmailQueue (BullMQ sobre Valkey) + +**What**: Criar fila `email` com conexão `ioredis` dedicada ao `VALKEY_URL` (`maxRetriesPerRequest: null`), expor `getEmailQueue()`, `enqueueEmail(data)` (com `attempts` = config, `backoff` exponencial) e `closeEmailQueue()`. +**Where**: `backend/src/modules/email/email.queue.ts` +**Depends on**: T1, T2 +**Reuses**: padrão singleton de `src/lib/cache.ts`; `VALKEY_URL`; `getConfig` +**Requirement**: EMAIL-01, EMAIL-03 + +**Tools**: +- MCP: `context7` (`Queue`/opções de `add` do BullMQ) +- Skill: NONE + +**Done when**: +- [ ] `enqueueEmail` adiciona job com `attempts` = `emailQueueAttempts` e backoff exponencial +- [ ] Conexão ioredis criada com `maxRetriesPerRequest: null` +- [ ] Testes unitários mockam `bullmq`/`ioredis` e asseveram opções do job +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T7: EmailWorker + +**What**: Criar `startEmailWorker()`/`stopEmailWorker()`: processor que renderiza o template (`renderTemplate`) e despacha via `getMailProvider().send(...)`; `on('failed')` loga erro no fracasso final. Falha do provider propaga (BullMQ re-tenta). +**Where**: `backend/src/modules/email/email.worker.ts` +**Depends on**: T3, T4, T5, T6 +**Reuses**: `getMailProvider`, `renderTemplate`, `logInfo/logError` +**Requirement**: EMAIL-02, EMAIL-03 + +**Tools**: +- MCP: `context7` (`Worker` do BullMQ) +- Skill: NONE + +**Done when**: +- [ ] Processor renderiza template e chama `provider.send({ to, subject, html })` com os valores renderizados +- [ ] Erro do provider é propagado pelo processor (permite retry do BullMQ) +- [ ] Handler `failed` registra erro (log) sem lançar +- [ ] Testes unitários mockam registry+provider e asseveram: payload enviado (to/subject/html), propagação de erro, log no failed +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T8: EmailService (API interna) + +**What**: Criar `EmailService` com `send({template,to,data})` — valida formato de `to` e existência de `template` (senão `AppError.validation`), enfileira via `enqueueEmail`; falha de enqueue é logada e **não** propagada. `sendWelcome({email,name})` injeta `appUrl` = `config.frontendUrl` e chama `send`. +**Where**: `backend/src/modules/email/email.service.ts` +**Depends on**: T5, T6 +**Reuses**: `enqueueEmail`, `isTemplate`, `AppError`, `logError`, `getConfig` +**Requirement**: EMAIL-01, EMAIL-05, EMAIL-06, EMAIL-07, EMAIL-10 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] `send` válido enfileira job com `{ template, to, data }` +- [ ] `to` inválido OU `template` desconhecido ⇒ `AppError.validation` e **nenhum** enqueue +- [ ] Falha no `enqueueEmail` ⇒ `send` resolve (erro logado, não propagado) +- [ ] `sendWelcome` injeta `appUrl` do `frontendUrl` e usa template `welcome` +- [ ] Testes unitários cobrem: enqueue no caminho feliz, validação (to/template) sem enqueue, enqueue-throw engolido, sendWelcome com appUrl +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: quick + +--- + +### T9: Iniciar/parar worker no boot do servidor + +**What**: Chamar `startEmailWorker()` no boot e `stopEmailWorker()` + `closeEmailQueue()` no graceful shutdown do servidor. +**Where**: `backend/src/server.ts` +**Depends on**: T6, T7 +**Reuses**: padrão de shutdown existente (`closeCache`) +**Requirement**: EMAIL-02 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] Worker inicia no boot do servidor +- [ ] Shutdown fecha worker e fila junto dos recursos existentes +- [ ] Gate passa: `cd backend && npm test` (suíte não quebra) + +**Tests**: none (wiring — build gate) +**Gate**: build + +--- + +### T10: Disparar boas-vindas no registro + +**What**: No `AuthService.register`, após o registro concluir com sucesso, chamar `emailService.sendWelcome({ email, name })` dentro de try/catch que apenas loga — nunca propaga. +**Where**: `backend/src/modules/auth/**` (arquivo do `register`) + teste correspondente +**Depends on**: T8 +**Reuses**: `EmailService`, `logError`, padrão de teste de auth existente +**Requirement**: EMAIL-06, EMAIL-08 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] Registro bem-sucedido chama `sendWelcome` com email + nome do usuário +- [ ] Exceção em `sendWelcome` NÃO altera o retorno de sucesso do registro +- [ ] Testes unitários: welcome enfileirado no sucesso; registro ainda retorna sucesso quando `sendWelcome` lança +- [ ] Gate passa: `cd backend && npm test` + +**Tests**: unit +**Gate**: full + +--- + +### T11: Documentação do módulo de e-mail + +**What**: Documentar uso do módulo (API interna `emailService.send/sendWelcome`, envs, como adicionar novo template/provider) no `BACKEND.md` (e/ou `docs/`). +**Where**: `BACKEND.md` +**Depends on**: T8 +**Reuses**: estilo do `BACKEND.md` +**Requirement**: EMAIL-11 + +**Tools**: +- MCP: NONE +- Skill: NONE + +**Done when**: +- [ ] Seção "Módulo de E-mail" com exemplo de uso, envs e passo p/ novo template/provider +- [ ] Gate passa: `cd backend && npm test` (docs não quebram nada) + +**Tests**: none (docs) +**Gate**: build + +--- + +## Phase Execution Map + +``` +Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 + +Phase 1: T1 ──→ T2 +Phase 2: T3 ──→ T4 +Phase 3: T5 +Phase 4: T6 ──→ T7 ──→ T8 +Phase 5: T9 ──→ T10 ──→ T11 +``` + +Execução estritamente sequencial — sem paralelismo intra-fase. + +--- + +## Task Granularity Check + +| Task | Scope | Status | +| --- | --- | --- | +| T1: deps + jsx | toolchain (2 arquivos coesos) | ✅ Granular | +| T2: config e-mail | 1 concern (config) | ✅ Granular | +| T3: interface + Noop + factory | 1 concern coeso (contrato+fallback) | ✅ Granular | +| T4: ResendProvider | 1 classe | ✅ Granular | +| T5: templates + registry | 1 concern coeso (templates+lookup) | ✅ Granular | +| T6: EmailQueue | 1 módulo (fila) | ✅ Granular | +| T7: EmailWorker | 1 módulo (worker) | ✅ Granular | +| T8: EmailService | 1 classe (API interna) | ✅ Granular | +| T9: boot wiring | 1 arquivo (server.ts) | ✅ Granular | +| T10: integração register | 1 função (register) | ✅ Granular | +| T11: docs | 1 arquivo (docs) | ✅ Granular | + +--- + +## Diagram-Definition Cross-Check + +| Task | Depends On (body) | Diagram Shows | Status | +| --- | --- | --- | --- | +| T1 | None | (início) | ✅ Match | +| T2 | None | (início) | ✅ Match | +| T3 | T2 | Phase 2 pós Phase 1 | ✅ Match | +| T4 | T1, T2, T3 | T3 → T4 (+ deps fase 1) | ✅ Match | +| T5 | T1 | Phase 3 pós Phase 1 | ✅ Match | +| T6 | T1, T2 | Phase 4 pós fase 1 | ✅ Match | +| T7 | T3, T4, T5, T6 | T6 → T7 (+ provider/template) | ✅ Match | +| T8 | T5, T6 | T7 → T8 na ordem; deps T5/T6 anteriores | ✅ Match | +| T9 | T6, T7 | Phase 5 pós Phase 4 | ✅ Match | +| T10 | T8 | pós Phase 4 | ✅ Match | +| T11 | T8 | pós Phase 4 | ✅ Match | + +> Todas as dependências apontam para trás ou para fase anterior — nenhuma dependência de fase posterior. + +--- + +## Test Co-location Validation + +| Task | Layer Created/Modified | Matrix Requires | Task Says | Status | +| --- | --- | --- | --- | --- | +| T1 | deps/tsconfig | none | none | ✅ OK | +| T2 | config | none | none | ✅ OK | +| T3 | provider/factory | unit | unit | ✅ OK | +| T4 | provider | unit | unit | ✅ OK | +| T5 | registry (+ templates none) | unit | unit | ✅ OK | +| T6 | queue | unit | unit | ✅ OK | +| T7 | worker | unit | unit | ✅ OK | +| T8 | service | unit | unit | ✅ OK | +| T9 | boot wiring | none | none | ✅ OK | +| T10 | auth service (integração) | unit | unit | ✅ OK | +| T11 | docs | none | none | ✅ OK | + +> Nenhum `Tests: none` esconde deferral: todos os `none` correspondem a camadas que a matriz marca como `none` (deps, config, wiring, docs, templates `.tsx`). + +--- + +## Sub-Agent Batching (para o Execute) + +Total: **11 tasks** em 5 fases `[2,2,1,3,3]`. Empacotando ~7 tasks/worker por fronteira de fase: + +- **Batch A — Blocos de construção** (Fases 1–3): T1–T5 (5 tasks) — deps/config, providers, templates. Folhas sem interdependência além das deps. +- **Batch B — Montagem + integração** (Fases 4–5): T6–T11 (6 tasks) — fila, worker, service, boot, registro, docs. Depende integralmente do Batch A. + +→ **2 workers**, sequenciais (Batch B só inicia após Batch A reportar tudo completo). Verifier roda automaticamente após T11. + +**Commits:** workers NÃO commitam (AGENTS.md). Após cada batch, o orchestrator apresenta os commits atômicos propostos (pt-BR) para aprovação do usuário. diff --git a/.specs/features/email-module/validation.md b/.specs/features/email-module/validation.md new file mode 100644 index 0000000..4eafb35 --- /dev/null +++ b/.specs/features/email-module/validation.md @@ -0,0 +1,123 @@ +# Email Module Validation + +**Date**: 2026-07-27 +**Spec**: `.specs/features/email-module/spec.md` +**Diff range**: `master..feature/pav-76-modulo-email` (Batch A committed) + working-tree changes (Batch B) + untracked email module files +**Verifier**: independent sub-agent (author ≠ verifier), read-only over the real tree; sensor mutations ran on temp copies and were discarded + +--- + +## Verdict: PASS ✅ + +Todos os 11 ACs (EMAIL-01..11) têm evidência `file:line` com assertion que mira o valor/estado definido na spec. Gate limpo (529/529). Sensor: 5/5 mutantes mortos. + +--- + +## Spec-Anchored Acceptance Criteria + +| AC | Spec-defined outcome | `file:line` + assertion | Result | +| -- | -------------------- | ----------------------- | ------ | +| **EMAIL-01** — `send({template,to,data})` válido enfileira e retorna sem aguardar entrega | Job enfileirado com payload exato `{template,to,data}` | `tests/unit/modules/email/email.service.test.ts:58` — `expect(queueMocks.enqueueEmail).toHaveBeenCalledWith({ template:"welcome", to:"user@example.com", data:{name:"Ana"} })` | ✅ PASS | +| **EMAIL-01** (conexão fila) — ioredis com `maxRetriesPerRequest:null` | Conexão criada com esse opt (requisito BullMQ) | `tests/unit/modules/email/email.queue.test.ts:69` — `expect(RedisConstructor).toHaveBeenCalledWith("redis://localhost:6379", { maxRetriesPerRequest: null })` | ✅ PASS | +| **EMAIL-02** — worker renderiza template e despacha via provider | Provider recebe `{to,subject,html}` renderizados | `email.worker.test.ts:94-101` — `expect(renderTemplate).toHaveBeenCalledWith("welcome",{name:"Ana"})` + `expect(providerMocks.send).toHaveBeenCalledWith({ to:"user@example.com", subject:"Bem-vindo", html:"

Olá

" })` | ✅ PASS | +| **EMAIL-02/04** (provider Resend concreto) — SDK chamado com from formatado + campos mapeados | `resend.emails.send({from:"Painel Vagas ", to, subject, html, replyTo})` | `resend.provider.test.ts:54-61` — `expect(resendMocks.send).toHaveBeenCalledWith({ from:"Painel Vagas ", to, subject, html, replyTo })` | ✅ PASS | +| **EMAIL-03** — falha do provider ⇒ retry com backoff; fracasso final ⇒ log, sem exceção ao caller | (a) enqueue com `attempts` do config + backoff exp; (b) worker propaga erro p/ BullMQ retry; (c) provider Resend lança em erro; (d) handler `failed` só loga | (a) `email.queue.test.ts:92` — `toHaveBeenCalledWith("send-email", data, objectContaining({ attempts:3, backoff:{type:"exponential",delay:2000} }))`; (b) `email.worker.test.ts:111` — `await expect(processor(job)).rejects.toThrow("provider down")`; (c) `resend.provider.test.ts:71` — `rejects.toThrow("rate limit")`; (d) `email.worker.test.ts:120-123` — `expect(()=>failedHandler(...)).not.toThrow()` + `expect(logError).toHaveBeenCalled()` | ✅ PASS | +| **EMAIL-04** — provider trocável via interface, sem mudar caller | `getMailProvider()` retorna Resend se key presente, Noop se ausente | `mail-provider.test.ts:57` — `expect(provider).toBeInstanceOf(FakeResend)` (key presente); `:47` — `toBeInstanceOf(FakeNoop)` (key vazia) | ✅ PASS | +| **EMAIL-05** — `to` inválido OU template desconhecido ⇒ rejeita no enqueue, antes de criar job | Lança `VALIDATION_ERROR` e NÃO enfileira | `email.service.test.ts:65-74` — `rejects.toMatchObject({code:"VALIDATION_ERROR"})` + `expect(enqueueEmail).not.toHaveBeenCalled()` (`to` inválido); `:77-88` — idem p/ template desconhecido. Registry: `registry.test.ts:14` — `isTemplate("desconhecido")` `.toBe(false)`; `:33` — `renderTemplate("desconhecido",...)` `.rejects.toThrow()` | ✅ PASS | +| **EMAIL-06** — registro com sucesso enfileira welcome p/ endereço do usuário | `sendWelcome` chamado com `{email,name}` corretos | `credentials.service.test.ts:201` — `expect(mocks.sendWelcome).toHaveBeenCalledWith({ email: mockUser.email, name: mockUser.displayName })` | ✅ PASS | +| **EMAIL-06/07** (service) — sendWelcome injeta appUrl do frontendUrl + template welcome | Payload `{template:"welcome", to, data:{name, appUrl:frontendUrl}}` | `email.service.test.ts:113` — `toHaveBeenCalledWith({ template:"welcome", to:"user@example.com", data:{ name:"Ana", appUrl:"https://painelvagas.com" } })` | ✅ PASS | +| **EMAIL-07** — welcome renderizado contém nome + botão "Acessar plataforma" com href=appUrl | HTML contém nome, `href="${appUrl}"`, e "Acessar plataforma"; subject presente | `registry.test.ts:28-30` — `expect(html).toContain("Maria")` + `expect(html).toContain('href="https://app.example.com"')` + `expect(html).toContain("Acessar plataforma")` (+ `subject` truthy/string `:26-27`) | ✅ PASS | +| **EMAIL-08** — falha do welcome não quebra o registro | `register` resolve com user+session mesmo com sendWelcome rejeitando | `credentials.service.test.ts:207-213` — `sendWelcome.mockRejectedValue(...)` então `expect(result.user).toMatchObject({email})` + `expect(result.session).toEqual({userId, role:"user"})` | ✅ PASS | +| **EMAIL-09** — env de e-mail ausente ⇒ no-op sem quebrar boot | (a) getMailProvider retorna Noop com key vazia; (b) Noop loga e resolve sem lançar | (a) `mail-provider.test.ts:47` — `toBeInstanceOf(FakeNoop)`; (b) `noop.provider.test.ts:28-33` — `resolves.toBeUndefined()` + `logWarn` chamado once + `ctx` `toMatchObject({to})` | ✅ PASS | +| **EMAIL-10** — Valkey down no enqueue ⇒ `send` loga e não propaga | `send` resolve `undefined` + `logError` chamado | `email.service.test.ts:91-102` — `enqueueEmail.mockRejectedValue(new Error("valkey down"))` então `resolves.toBeUndefined()` + `expect(logError).toHaveBeenCalled()` | ✅ PASS | +| **EMAIL-11** — documentação de uso no repositório | Seção de módulo em doc do repo | `BACKEND.md` — nova seção "## Módulo de E-mail" (fluxo, arquivos, uso, envs, como adicionar template/provider) + envs listadas na seção de variáveis | ✅ PASS (verificação documental; sem teste automatizado — apropriado) | + +**Status**: ✅ Todos os 11 ACs cobertos com assertion não-rasa alinhada ao outcome da spec. + +### Edge cases da spec + +- [x] Env de e-mail ausente ⇒ no-op sem quebrar boot — EMAIL-09 (mail-provider + noop tests). +- [x] Valkey down no enqueue ⇒ não propaga — EMAIL-10 (email.service.test.ts:91). +- [x] Template inexistente ⇒ enqueue falha com validação (sem job órfão) — EMAIL-05 (email.service.test.ts:77 + registry.test.ts:33). +- [~] Dois registros do mesmo usuário ⇒ jobs independentes (sem dedup) — comportamento default aceito no MVP; sem teste dedicado, mas a spec o marca como "aceitável" e não define outcome preciso. Não é gap. + +--- + +## Discrimination Sensor + +Mutações injetadas UMA POR VEZ em cópias temporárias (backup em `/tmp`, restauradas após cada rodada). Árvore real intacta ao final. + +| # | Alvo | Mutação | Teste executado | Killed? | +| - | ---- | ------- | --------------- | ------- | +| 1 | `email.service.ts:22` | Desativa validação de `to` (`if (false && ...)`) | `email.service.test.ts` | ✅ Killed (1 failed) | +| 2 | `email.service.ts:30-38` | Remove try/catch — deixa a falha de enqueue PROPAGAR | `email.service.test.ts` | ✅ Killed (teste de resiliência EMAIL-10) | +| 3 | `email.worker.ts:25` | Engole o erro do provider (try/catch vazio) em vez de propagar | `email.worker.test.ts` | ✅ Killed (teste de propagação/retry EMAIL-03) | +| 4 | `welcome.tsx:24` | `href={appUrl}` → `href="#"` (CTA não aponta p/ appUrl) | `registry.test.ts` | ✅ Killed (EMAIL-07) | +| 5 | `credentials.service.ts:87-97` | Remove try/catch — propaga exceção do sendWelcome | `credentials.service.test.ts` | ✅ Killed (EMAIL-08) | + +**Sensor depth**: lightweight fault-injection (5 mutações, alta cobertura das branches de resiliência + payload). +**Result**: 5/5 killed — PASS ✅. Nenhum mutante sobrevivente ⇒ nenhuma fix task. + +--- + +## Code Quality + +| Principle | Status | +| --------- | ------ | +| Minimum code (sem features além do pedido) | ✅ | +| Surgical changes (só arquivos necessários) | ✅ | +| No scope creep (contato/suporte fora, como spec exige) | ✅ | +| Matches patterns (Service coeso, hoisted mocks, singleton lazy à la cache.ts) | ✅ | +| Spec-anchored outcome check (valores asseverados batem com a spec) | ✅ | +| Per-layer coverage (service/queue/worker/providers/registry/integração cada um com happy+edge+erro) | ✅ | +| Todo teste mapeia a um AC/edge/Done-when (sem testes órfãos) | ✅ | +| Guidelines: threshold 80% Vitest; mock de Valkey/BullMQ/provider (design §Risks) seguido | ✅ | + +Observação (não-bloqueante): `EmailQueue`/`EmailWorker`/`ResendProvider` são exercitados via mocks de `bullmq`/`ioredis`/`resend` — apropriado para unit (design marca conexão real como out-of-scope de teste). Não há teste de integração de ponta-a-ponta com Valkey real, o que é consistente com o design (risco "testes lentos/flaky se conectarem de verdade"). + +--- + +## Gate Check + +- **Gate command**: `cd backend && npm test` (`vitest run`) +- **Result**: 529 passed, 0 failed, 0 skipped (55 test files) +- **Delta**: testes de e-mail novos (email.service 5, email.queue 5, email.worker 3, registry 4, mail-provider 2, noop 1, resend 2) + 2 novos em credentials.service (EMAIL-06/08) +- **Flaky pré-existentes**: nenhum acionado nesta rodada; `savedJobs.routes`/`app.test` passaram. +- **Failures**: nenhuma. +- Rodado 2x (antes e depois do sensor) — verde em ambas. + +--- + +## Requirement Traceability Update + +| Requirement | Previous Status | New Status | +| ----------- | --------------- | ---------- | +| EMAIL-01 | Implementing | ✅ Verified | +| EMAIL-02 | Implementing | ✅ Verified | +| EMAIL-03 | Implementing | ✅ Verified | +| EMAIL-04 | Implementing | ✅ Verified | +| EMAIL-05 | Implementing | ✅ Verified | +| EMAIL-06 | Implementing | ✅ Verified | +| EMAIL-07 | Implementing | ✅ Verified | +| EMAIL-08 | Implementing | ✅ Verified | +| EMAIL-09 | Implementing | ✅ Verified | +| EMAIL-10 | Implementing | ✅ Verified | +| EMAIL-11 | Implementing | ✅ Verified | + +--- + +## Summary + +**Overall**: ✅ Ready + +**Spec-anchored check**: 11/11 ACs matched spec outcome — 0 spec-precision gaps. +**Sensor**: 5/5 mutations killed. +**Gate**: 529 passed, 0 failed. + +**What works**: API interna `emailService.send/sendWelcome` (validação + enqueue não-bloqueante), worker render+dispatch, retry/backoff, provider trocável (Resend/Noop) via interface, welcome com nome+CTA para FRONTEND_URL, resiliência (enqueue e welcome nunca quebram o caller), no-op sem env, documentação no BACKEND.md. + +**Issues found**: nenhum. + +**Diff surface covered**: `backend/src/modules/email/**` (service, queue, worker, providers, templates), `credentials.service.ts` (integração welcome), `config.ts`, `server.ts` (boot/shutdown do worker), `.env.example`, `tsconfig.json`, `vitest.config.js`, `BACKEND.md`. `server.ts` boot/shutdown e `config.ts` parsing não têm assertion unitária dedicada, mas seus comportamentos observáveis (worker sobe/desce; envs lidas) são cobertos indiretamente pelos testes de worker/queue/service que consomem esses valores — nível de risco baixo, consistente com o design. + +**Next steps**: marcar a feature como Verified; nenhuma fix task. diff --git a/BACKEND.md b/BACKEND.md index 9c7512c..b2eb227 100644 --- a/BACKEND.md +++ b/BACKEND.md @@ -84,6 +84,58 @@ Cache & Indexes: - `src/lib/cache.ts` — helpers para Redis/Valkey; usado por `jobs.routes` para obter ids e buscar vagas em memória. - Busca por palavras-chave usa índices invertidos e interseção para eficiência. +## Módulo de E-mail + +Módulo centralizado em `src/modules/email` para envio de e-mails transacionais de forma **assíncrona e resiliente**. Qualquer módulo do backend consome a mesma API interna (`emailService`), sem conhecer o provedor. + +**Fluxo:** `emailService.send()` valida e enfileira um job na fila BullMQ `email` (sobre Valkey, via `ioredis`) → um worker in-process (`startEmailWorker`, iniciado no boot do `server.ts`) renderiza o template react-email e despacha pelo `MailProvider` configurado. Falha do provedor aciona retry com backoff exponencial; no fracasso final apenas loga. Falha ao enfileirar (ex.: Valkey indisponível) é logada e **não** propaga para o fluxo de negócio chamador. + +**Arquivos:** + +- `email.service.ts` — API interna (`emailService.send` / `sendWelcome`). +- `email.queue.ts` — fila BullMQ + conexão `ioredis` dedicada (`getEmailQueue`, `enqueueEmail`, `closeEmailQueue`). +- `email.worker.ts` — worker in-process (`startEmailWorker`, `stopEmailWorker`). +- `providers/mail-provider.ts` — interface `MailProvider` + `getMailProvider()` (seleciona `ResendProvider` se `EMAIL_API_KEY` presente, senão `NoopProvider`). +- `providers/resend.provider.ts`, `providers/noop.provider.ts` — provedores concretos. +- `templates/registry.ts` + `templates/*.tsx` — templates react-email e lookup por nome. + +**Uso (API interna):** + +```ts +import { emailService } from "./modules/email/email.service"; + +// Envio genérico: valida `to` (formato) e `template` (existe no registry). +await emailService.send({ + template: "welcome", + to: "usuario@exemplo.com", + data: { name: "Ana", appUrl: "https://painelvagas.com" }, +}); + +// Açúcar para boas-vindas: injeta `appUrl` a partir de FRONTEND_URL. +await emailService.sendWelcome({ email: "usuario@exemplo.com", name: "Ana" }); +``` + +`send` resolve sem aguardar a entrega. `to` inválido ou `template` desconhecido lançam `AppError.validation` (antes de enfileirar). + +**Variáveis de ambiente:** + +- `EMAIL_API_KEY` — chave da Resend. Vazia ⇒ `NoopProvider` (apenas loga; não envia, não quebra o boot). +- `EMAIL_FROM_ADDRESS` — endereço remetente (ex.: `no-reply@painelvagas.com`). +- `EMAIL_FROM_NAME` — nome exibido do remetente (ex.: `Painel Vagas`). +- `EMAIL_QUEUE_ATTEMPTS` — nº de tentativas do job (padrão `3`). +- `FRONTEND_URL` — reusada para o botão "Acessar plataforma" do template de boas-vindas (nenhuma env de URL nova é criada). + +**Adicionar um novo template:** + +1. Crie o componente react-email em `src/modules/email/templates/.tsx` (props tipadas), reusando `BaseLayout`. +2. Registre-o no mapa `templates` de `registry.ts` (subject + component) e adicione as props em `TemplateDataMap`. O `TemplateName` e `isTemplate` passam a reconhecê-lo automaticamente. +3. Consuma via `emailService.send({ template: "", to, data })`. + +**Adicionar um novo provider:** + +1. Implemente a interface `MailProvider` (`send({ to, subject, html, replyTo? })`) em `src/modules/email/providers/.provider.ts`. Em falha, **lance** (para o BullMQ re-tentar). +2. Ajuste `getMailProvider()` em `mail-provider.ts` para selecioná-lo pela configuração. Nenhum caller precisa mudar (contrato via interface). + ## Middlewares - `withSession` — integra `iron-session` (sessões + cookie `vagas_session`). @@ -171,7 +223,12 @@ 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 (se usado). +- `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. +- `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. - `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. diff --git a/GUIA_TESTES_AUTOMATIZADOS.md b/GUIA_TESTES_AUTOMATIZADOS.md new file mode 100644 index 0000000..4ce20f6 --- /dev/null +++ b/GUIA_TESTES_AUTOMATIZADOS.md @@ -0,0 +1,337 @@ +# Guia de Testes Automatizados + +Este documento foi criado para quem nunca trabalhou com testes automatizados. + +Se voce esta contribuindo pela primeira vez no projeto, começe por aqui. + +## O que e um teste automatizado? + +Um teste automatizado e um pequeno programa que verifica se uma funcionalidade continua funcionando como esperado. + +Em vez de testar tudo manualmente toda vez, o teste automatizado executa cenarios e confirma o resultado por voce. + +## Por que escrevemos testes? + +Escrevemos testes para: + +- evitar quebrar funcionalidades antigas +- dar seguranca para melhorar o codigo +- documentar como o sistema deve se comportar +- facilitar revisao de Pull Request +- ajudar novos contribuidores a entender o projeto + +## O que acontece quando um teste falha? + +Quando um teste falha, isso e um alerta. + +Pode significar: + +- bug novo +- comportamento mudou sem querer +- regra esperada deixou de ser atendida + +Em geral, um teste falhando e uma protecao do projeto, nao um incomodo. + +## Como testes ajudam novos desenvolvedores + +Testes funcionam como documentacao viva. + +Ao ler um teste, voce entende: + +- qual entrada foi usada +- qual comportamento era esperado +- o que nao pode regredir + +--- + +## Conceitos basicos + +### O que e um cenario de teste? + +Cenario de teste e uma situacao real do sistema. + +Exemplo simples: + +"Quando faco login com senha correta, espero entrar no sistema." + +Isso vira: + +- Cenario: usuario informa senha correta +- Resultado esperado: sistema permite acesso + +--- + +## O que e um TC (Test Case) + +TC significa Test Case (Caso de Teste). + +Um TC responde: + +"Qual comportamento do sistema estou garantindo?" + +Importante: + +- TC nao e uma linha de codigo isolada +- TC e um comportamento relevante + +Exemplo real do projeto: + +- backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts + +Responsabilidade desse TC: + +- garantir que o adapter retorna vagas quando o Go Scraper responde com dados validos + +--- + +## O que e um TP (Test Plan) + +TP significa Test Plan (Plano de Testes). + +TP e um documento/arquivo que organiza: + +- o que sera testado +- quais cenarios existem +- quais TCs pertencem ao plano +- resultados esperados + +Exemplo real do projeto: + +- backend/tests/unit/adapters/TP-01.goScraper.test.ts + +Esse arquivo representa o planejamento dos testes do adapter Go Scraper. + +Ele define: + +- qual funcionalidade sera validada +- qual o objetivo dos testes +- quais cenarios precisam ser protegidos +- quais TCs fazem parte desse plano + +Antes de escrever codigo de teste, o primeiro passo e entender quais comportamentos precisam ser garantidos. + +--- + +## Relacao entre TP e TC + +O projeto utiliza esta relacao: + +TP (Test Plan) +-> +TC (Test Case) +-> +Teste automatizado executavel + +Explicando de forma simples: + +- TP: planeja os cenarios +- TC: implementa cada cenario +- Suite de testes: executa tudo e valida o comportamento + +### Estrutura de relacionamento (Go Scraper) + +```text +Go Scraper Adapter + +TP-01.goScraper.test.ts + +Define os cenarios: + +|- TC-01 +| |- Resposta valida +| +|- TC-02 +| |- Erro HTTP +| +|- TC-03 +| |- Contrato invalido +| +|- TC-04 +| |- Parametros invalidos +| +|- TC-05 +| |- Envio de parametros +| +|- TC-06 +| |- Variavel de ambiente +``` + +--- + +## Explicacao dos TCs reais do projeto + +Arquivos reais atualmente: + +- backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC02.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC03.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC04.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC05.ts +- backend/tests/unit/adapters/goScrapper/goScraper.TC06.ts + +Resumo de cada cenario: + +- TC01 + - Cenario: resposta valida do Go Scraper + - Esperado: adapter retorna vagas corretamente + +- TC02 + - Cenario: servico externo retorna erro HTTP + - Esperado: adapter trata e propaga erro corretamente + +- TC03 + - Cenario: resposta nao segue contrato esperado + - Esperado: sistema rejeita resposta invalida + +- TC04 + - Cenario: parametros invalidos de entrada + - Esperado: falha antes de chamar servico externo + +- TC05 + - Cenario: envio de parametros para o servico + - Esperado: payload preserva dados esperados + +- TC06 + - Cenario: URL do Go Scraper via variavel de ambiente + - Esperado: adapter usa GO_SCRAPER_URL configurada + +--- + +## Primeiro exemplo do projeto (passo a passo) + +Use como referencia: + +- backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts + +Passo a passo do que ele faz: + +1. prepara uma resposta simulada (mock) da API externa +2. executa o adapter +3. verifica os dados retornados +4. confirma que o comportamento esperado aconteceu + +--- + +## Regra de qualidade dos testes + +Teste unitario nao deve validar apenas uma linha ou chamada isolada. + +Exemplo ruim: + +```ts +it("deve chamar funcao", () => { + expect(mock.execute).toHaveBeenCalled(); +}); +``` + +Por que e ruim: + +- valida detalhe interno +- nao prova comportamento real + +Exemplo melhor: + +```ts +it("deve retornar vagas quando a resposta do Go Scraper for valida", async () => { + // cenario completo +}); +``` + +Pergunta que todo bom teste deve responder: + +"Qual comportamento do sistema estou garantindo?" + +--- + +## Quando usar varios testes e quando usar it.each + +Use TCs separados quando os cenarios sao diferentes. + +Exemplo: + +- resposta valida +- erro HTTP +- contrato invalido + +Use `it.each` quando a regra e a mesma e so os dados mudam. + +Exemplo: + +```ts +it.each([ + { keyword: "Java" }, + { keyword: "Node.js" }, +])( "deve buscar vagas corretamente", async ({ keyword }) => { + // mesma regra, dados diferentes +}); +``` + +Isso evita copiar e colar testes quase iguais. + +--- + +## Processo para criar novos testes + +1. Identificar a funcionalidade +2. Criar ou atualizar o TP +3. Definir cenarios +4. Criar TCs necessarios +5. Implementar os testes +6. Executar testes localmente +7. Abrir Pull Request + +Comando util para backend: + +```bash +npm run test --workspace=backend +``` + +--- + +## Beneficios desse padrao + +Para iniciantes: + +- facilita entender o projeto +- funciona como documentacao viva +- ajuda na primeira contribuicao + +Para o projeto: + +- melhora manutencao +- facilita revisao de PR +- reduz regressao +- mantem qualidade com crescimento da comunidade + +--- + +## Checklist para novos contribuidores + +Antes de abrir PR, confirme: + +- Existe um TP documentando o objetivo? +- O TC representa um comportamento real? +- O nome do teste esta claro? +- Evitei testar detalhes internos? +- Usei it.each quando os cenarios sao equivalentes? + +--- + +## Nota de consistencia sobre nomes dos arquivos + +Este guia usa os caminhos reais encontrados no repositorio no momento da escrita. + +No prompt de referencia, alguns nomes aparecem como: + +- TC-01.goScraper.test.ts +- TP-01.goScraper.test.ts (como documento) + +No codigo atual, os arquivos de TC estao nomeados como: + +- goScraper.TC01.ts ate goScraper.TC06.ts + +Isso nao muda o conceito. + +O importante e manter a relacao: + +TP planeja -> TC implementa -> teste automatizado protege comportamento. diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md new file mode 100644 index 0000000..315c6ac --- /dev/null +++ b/LOCAL_DEVELOPMENT.md @@ -0,0 +1,651 @@ +# Guia de Desenvolvimento Local + +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. + +## Visão rápida + +O monorepo possui 5 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, há Docker Compose para infraestrutura e execução completa. + +--- + +## 1) Pré-requisitos + +### 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) + +### Opcionais (dependendo do fluxo) + +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) + +### Como verificar instalação + +No terminal: + +```bash +git --version +node -v +npm -v +docker --version +docker compose version +``` + +Opcional (Go): + +```bash +go version +``` + +### Versão recomendada de gerenciador de pacotes + +- Recomendado pelo projeto: npm +- Observação: existe pnpm-workspace.yaml, mas o lockfile ativo do projeto é package-lock.json. + +--- + +## 2) Clonando o projeto + +Exemplo: + +```bash +git clone https://github.com/Cla-Code-Community/candidate.git +cd candidate +``` + +Se seu fork/repo tiver outro nome, ajuste os comandos. + +--- + +## 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 + +--- + +## 4) Instalação + +Execute na raiz do monorepo: + +```bash +npm install +``` + +Isso instala dependências da raiz e dos workspaces. + +--- + +## 5) Variáveis de ambiente + +## Arquivos de ambiente existentes + +No estado atual do repositório, existem: + +- .env.example (raiz) +- backend/.env.example +- frontend/.env.example + +Também podem existir localmente após setup: + +- .env +- backend/.env + +Observação importante: + +- front_admin não possui front_admin/.env.example versionado. + +## Como criar + +Na raiz: + +```bash +cp .env.example .env +cp backend/.env.example backend/.env +cp frontend/.env.example frontend/.env +``` + +No PowerShell: + +```powershell +Copy-Item .env.example .env +Copy-Item backend/.env.example backend/.env +Copy-Item frontend/.env.example frontend/.env +``` + +## Variáveis obrigatórias vs opcionais + +### Obrigatórias na prática para uso completo + +1. SESSION_SECRET +2. DATABASE_URL +3. CORS_ALLOWED_ORIGINS +4. FRONTEND_URL +5. GO_SCRAPER_URL (ou SCRAPER_URL em alguns fluxos) + +### Necessárias somente se usar OAuth + +1. GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET +2. GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET +3. LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET + +### Necessárias somente para fontes externas de scraping + +1. ADZUNA_APP_ID / ADZUNA_APP_KEY +2. JOOBLE_API_KEY + +### Segurança/PII + +1. ENCRYPTION_MASTER_KEY +2. SEARCH_KEY +3. ENCRYPTION_KEY_ID + +Se esses valores não estiverem definidos adequadamente, recursos que dependem de criptografia e busca segura podem falhar. + +--- + +## 6) Banco de dados + +## O que existe hoje + +- ORM: Drizzle +- Dialeto: PostgreSQL +- Migrações: backend/drizzle +- Config Drizzle: backend/drizzle.config.js +- Não há arquivos de seeders versionados no repositório. + +## Migrações (manual) + +Rodando no workspace backend: + +```bash +npm run db:migrate --workspace=backend +``` + +Alternativas: + +```bash +npm run db:generate --workspace=backend +npm run db:push --workspace=backend +``` + +## Migrações via Docker + +No fluxo Docker completo, há o serviço migrate em docker-compose.migrate.yml que roda: + +- db:migrate +- security:backfill-user-pii + +## Usuários padrão + +Não há usuários padrão documentados como seed no repositório. + +Como criar usuário para testes: + +1. Use a tela de cadastro em /register +2. Ou envie POST /auth/register + +--- + +## 7) Docker + +## 7.1 Subir stack completa (recomendado para onboarding) + +1. Criar rede: + +```bash +docker network create vagas-net +``` + +2. Subir infra + app + migrate: + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml up --build -d +``` + +## 7.2 Parar stack + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml down +``` + +## 7.3 Rebuild + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml up --build -d +``` + +## 7.4 Logs + +Logs de todos os serviços: + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml logs -f +``` + +Logs de um serviço específico (exemplo backend): + +```bash +docker compose -f docker-compose.infra.yml -f docker-compose.yml -f docker-compose.migrate.yml logs -f backend +``` + +## 7.5 Observabilidade (opcional) + +Subir stack de observabilidade: + +```bash +docker compose -f docker-compose.observability.yml up -d +``` + +Parar: + +```bash +docker compose -f docker-compose.observability.yml down +``` + +--- + +## 8) Executando o projeto + +Você tem 2 caminhos principais. + +## Caminho A: Docker completo (mais simples para começar) + +Use o comando da seção de Docker. + +Portas esperadas: + +- frontend: http://localhost:5173 +- front_admin: http://localhost:5174 +- backend: http://localhost:3001 +- scraper-go: http://localhost:8081 + +## Caminho B: Node local (frontend + backend) + +Na raiz: + +```bash +npm run dev +``` + +Isso sobe: + +- frontend em 5173 +- backend em 3001 + +Para incluir admin junto: + +```bash +npm run dev:admin +``` + +Comandos separados: + +```bash +npm run dev:frontend +npm run dev:backend +npm run dev:front_admin +``` + +Observação importante para o Caminho B: + +- 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. + +--- + +## 9) Acessando a aplicação + +URLs principais: + +- App principal: http://localhost:5173 +- Login: http://localhost:5173/login +- Cadastro: http://localhost:5173/register +- Dashboard app: /home, /dashboard, /vagas, /mentoria, /perfil, /ajuda +- Callback OAuth: /auth/callback + +Backend: + +- Health: http://localhost:3001/health +- Swagger: http://localhost:3001/docs +- Metrics: http://localhost:3001/metrics + +Scraper: + +- Health: http://localhost:8081/health +- Metrics: http://localhost:8081/metrics +- Admin jobs count: http://localhost:8081/admin/jobs/count + +Front admin: + +- http://localhost:5174 +- rota de login: /login +- rotas principais: /dashboard, /users, /scrapers, /observability, /audit, /permissions, /settings + +## Login/senha padrão + +Não há credenciais padrão versionadas/documentadas para produção/local no repositório. + +Fluxo recomendado para ambiente local: + +1. criar usuário via cadastro na aplicação principal +2. usar login com email/senha criados + +Para OAuth, é necessário configurar credenciais de provedores no .env. + +--- + +## 10) Fluxo da aplicação (visão funcional) + +## 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 + +## Backend + +- Sessão via cookie (iron-session) +- Rotas protegidas para usuários autenticados: + - /users + - /jobs + - /keywords + - /notifications + - /saved-jobs + - /admin + +## Scraper e fila + +- Backend pode enfileirar keywords no Valkey (chave scraper:keywords:pending) +- Scraper-go processa keywords e agrega vagas + +## Front admin + +- Login próprio do painel +- Controle de acesso por papel (support/admin/super_admin) +- Seções administrativas para operação da plataforma + +--- + +## 11) Testando manualmente (roteiro prático) + +Abaixo, os testes manuais sugeridos para os módulos principais. + +## 11.1 Login + +Passos: + +1. Acesse http://localhost:5173/login +2. Tente enviar vazio +3. Informe credenciais inválidas +4. Informe credenciais válidas + +Resultado esperado: + +- validações de campo aparecem +- credenciais inválidas não autenticam +- credenciais válidas redirecionam para área protegida + +## 11.2 Cadastro + +Passos: + +1. Acesse http://localhost:5173/register +2. Preencha campos obrigatórios +3. Teste telefone opcional vazio +4. Teste telefone válido +5. Teste telefone inválido + +Resultado esperado: + +- cadastro válido cria conta e redireciona para login +- telefone vazio é permitido +- telefone inválido exibe erro e bloqueia envio + +## 11.3 Busca de vagas + +Passos: + +1. Faça login +2. Vá para /vagas +3. Acione busca/filtros + +Resultado esperado: + +- requests de busca retornam sem quebrar a UI +- estados de loading/erro são exibidos corretamente + +## 11.4 Vagas salvas + +Passos: + +1. Em /vagas, salve uma vaga +2. Abra lista de salvas +3. Edite status/notas se disponível +4. Remova vaga salva + +Resultado esperado: + +- operações de criar/editar/remover refletem na interface + +## 11.5 Perfil e preferências + +Passos: + +1. Vá para /perfil +2. Atualize dados do perfil +3. Atualize preferências + +Resultado esperado: + +- alterações persistem +- recarregar a tela mantém dados + +## 11.6 Painel administrativo + +Passos: + +1. Acesse http://localhost:5174/login +2. Faça login com conta com permissão +3. Navegue por dashboard/users/scrapers/observability/audit/permissions/settings + +Resultado esperado: + +- acesso a páginas conforme papel +- usuário sem papel mínimo deve cair em 403 + +--- + +## 12) Como reproduzir bugs corretamente + +Use sempre este formato: + +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 + +Modelo: + +- Passos: + 1. ... + 2. ... + 3. ... +- Resultado atual: ... +- Resultado esperado: ... + +Exemplo real (telefone): + +- 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 + +--- + +## 13) Testes automatizados + +## 13.1 Monorepo (cobertura consolidada) + +Na raiz: + +```bash +npm run test:coverage +``` + +## 13.2 Backend + +```bash +npm run test --workspace=backend +npm run test:coverage --workspace=backend +npm run test:watch --workspace=backend +``` + +## 13.3 Frontend + +```bash +npm run test --workspace=frontend +npm run test:coverage --workspace=frontend +npm run test:watch --workspace=frontend +``` + +## 13.4 Front admin + +```bash +npm run test --workspace=front_admin +npm run test:coverage --workspace=front_admin +``` + +## 13.5 Testes de integração e E2E + +- 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. + +--- + +## 14) Checklist antes de abrir Pull Request + +Use esta checklist: + +- [ ] 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) + +--- + +## Comandos úteis extras + +Builds: + +```bash +npm run build:frontend +npm run build:front_admin +``` + +Validação rápida da raiz: + +```bash +npm run validate +``` + +Electron: + +```bash +npm run electron +npm run electron:dev +``` + +--- + +## Referências do projeto + +- 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 + +--- + +## Lacunas identificadas no estado atual (sem suposição) + +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.). + +Se você for manter este guia, priorize resolver essas lacunas para reduzir tempo de onboarding. diff --git a/README.md b/README.md index 3d419c9..0fe7ce1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Painel de Vagas -[![CI](https://github.com/Benevanio/Jobs_Scraper_Global/actions/workflows/ci.yml/badge.svg)](https://github.com/Benevanio/Jobs_Scraper_Global/actions/workflows/ci.yml) +> Novo no projeto? Comece por aqui: [LOCAL_DEVELOPMENT.md](LOCAL_DEVELOPMENT.md) + +[![CI](https://github.com/Cla-Code-Community/candidate/actions/workflows/ci.yml/badge.svg)](https://github.com/Cla-Code-Community/candidate/actions/workflows/ci.yml) ![Node >= 22](https://img.shields.io/badge/node-%3E%3D22-339933) ![Monorepo](https://img.shields.io/badge/architecture-monorepo-0A66C2) ![License ISC](https://img.shields.io/badge/license-ISC-lightgrey) diff --git a/backend/package.json b/backend/package.json index 199dfe6..1556c60 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,17 +35,23 @@ "author": "Projeto Open Source - Bene Tesla Dev", "license": "ISC", "dependencies": { + "@react-email/render": "^2.1.0", "argon2": "^0.44.0", "axios": "^1.17.0", + "bullmq": "^5.81.2", "cheerio": "^1.2.0", "cors": "^2.8.6", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "ioredis": "^5.11.1", "iron-session": "^8.0.4", "pdfkit": "^0.18.0", "prom-client": "^15.1.3", + "react": "^19.2.8", + "react-dom": "^19.2.8", "redis": "^5.12.1", + "resend": "^6.18.0", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", "xlsx": "^0.18.5", @@ -57,6 +63,7 @@ "devDependencies": { "@types/node": "^25.9.1", "@types/pg": "^8.20.0", + "@types/react": "^19.2.17", "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^4.1.8", "drizzle-kit": "^0.31.10", diff --git a/backend/src/config.ts b/backend/src/config.ts index fc98ab7..7856822 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -17,6 +17,11 @@ export interface AppConfig { timeFilter: string; databaseUrl: string; valkeyUrl: string; + frontendUrl: string; + emailApiKey: string; + emailFromAddress: string; + emailFromName: string; + emailQueueAttempts: number; } function parseBoolean(value: string | undefined, fallback: boolean): boolean { @@ -65,5 +70,10 @@ export function getConfig(): AppConfig { timeFilter: parseTimeFilter(process.env.TIME_FILTER, "r604800"), databaseUrl: process.env.DATABASE_URL?.trim() ?? "", valkeyUrl: process.env.VALKEY_URL?.trim() ?? "", + frontendUrl: process.env.FRONTEND_URL?.trim() ?? "", + emailApiKey: process.env.EMAIL_API_KEY?.trim() ?? "", + emailFromAddress: process.env.EMAIL_FROM_ADDRESS?.trim() ?? "", + emailFromName: process.env.EMAIL_FROM_NAME?.trim() ?? "", + emailQueueAttempts: parseNumber(process.env.EMAIL_QUEUE_ATTEMPTS, 3), }; } diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 2bd8384..b5f5dba 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -1,4 +1,6 @@ import { User } from "../../db/schema/users"; +import { logError } from "../../logger"; +import { emailService } from "../email/email.service"; import type { AuthCallbackParams, OAuthProfile, @@ -39,11 +41,16 @@ export class AuthService { throw new Error("oauth_email_required"); } - const user = await findOrCreateUser({ + const { user, isNewUser } = await findOrCreateUser({ provider, profile, }); + // Boas-vindas apenas no primeiro login social (usuário recém-criado). + if (isNewUser) { + await this.sendWelcomeEmail(user); + } + const session = await this.createSession(user); return { @@ -52,6 +59,26 @@ 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 { + if (!user.email) return; + + try { + await emailService.sendWelcome({ + email: user.email, + name: user.displayName ?? user.username, + }); + } catch (error) { + logError("Falha ao disparar e-mail de boas-vindas no login social.", { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + async getProfileFromProvider({ provider, code, diff --git a/backend/src/modules/auth/credentials.service.ts b/backend/src/modules/auth/credentials.service.ts index a46a081..ef50c67 100644 --- a/backend/src/modules/auth/credentials.service.ts +++ b/backend/src/modules/auth/credentials.service.ts @@ -4,6 +4,8 @@ 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"; import { encryptText } from "../../lib/security/encryption"; import { normalizeEmail } from "../../lib/security/normalization"; import { generateSearchableHash } from "../../lib/security/searchableHash"; @@ -81,6 +83,19 @@ export class CredentialsService { return createdUser; }); + // E-mail de boas-vindas: falha nunca derruba o registro (EMAIL-08). + try { + await emailService.sendWelcome({ + email: user.email, + name: user.displayName ?? user.username, + }); + } catch (error) { + logError("Falha ao disparar e-mail de boas-vindas.", { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + } + return { user, session: { userId: user.id, role: user.role } }; } diff --git a/backend/src/modules/email/email.queue.ts b/backend/src/modules/email/email.queue.ts new file mode 100644 index 0000000..cac43aa --- /dev/null +++ b/backend/src/modules/email/email.queue.ts @@ -0,0 +1,72 @@ +import { Queue } from "bullmq"; +import IORedis, { type Redis } from "ioredis"; +import { getConfig } from "../../config"; +import type { TemplateName } from "./templates/registry"; + +/** + * Nome da fila BullMQ e do job de envio de e-mail. + */ +export const EMAIL_QUEUE_NAME = "email"; +const EMAIL_JOB_NAME = "send-email"; + +/** + * Payload do job enfileirado. Efêmero — sem persistência em DB (só na fila). + */ +export interface EmailJobData { + template: TemplateName; + to: string; + data: Record; +} + +let _connection: Redis | null = null; +let _queue: Queue | null = null; + +/** + * Conexão ioredis dedicada ao BullMQ, separada do node-redis do cache. + * `maxRetriesPerRequest: null` é exigido pelo BullMQ. + */ +export function getEmailConnection(): Redis { + if (_connection) return _connection; + + const { valkeyUrl } = getConfig(); + _connection = new IORedis(valkeyUrl, { maxRetriesPerRequest: null }); + return _connection; +} + +/** + * Singleton lazy da fila `email` sobre Valkey (via ioredis). + */ +export function getEmailQueue(): Queue { + if (_queue) return _queue; + + _queue = new Queue(EMAIL_QUEUE_NAME, { + connection: getEmailConnection(), + }); + return _queue; +} + +/** + * Enfileira um job de e-mail com retry/backoff configuráveis (EMAIL-01/03). + */ +export async function enqueueEmail(data: EmailJobData): Promise { + const { emailQueueAttempts } = getConfig(); + + await getEmailQueue().add(EMAIL_JOB_NAME, data, { + attempts: emailQueueAttempts, + backoff: { type: "exponential", delay: 2000 }, + }); +} + +/** + * Fecha a fila e a conexão no graceful shutdown. + */ +export async function closeEmailQueue(): Promise { + if (_queue) { + await _queue.close(); + _queue = null; + } + if (_connection) { + await _connection.quit(); + _connection = null; + } +} diff --git a/backend/src/modules/email/email.service.ts b/backend/src/modules/email/email.service.ts new file mode 100644 index 0000000..3f3e302 --- /dev/null +++ b/backend/src/modules/email/email.service.ts @@ -0,0 +1,65 @@ +import { getConfig } from "../../config"; +import { AppError } from "../../lib/errors"; +import { logError } from "../../logger"; +import { enqueueEmail } from "./email.queue"; +import { isTemplate, type TemplateName } from "./templates/registry"; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export interface SendEmailInput { + template: TemplateName; + to: string; + data: Record; +} + +/** + * API interna única de envio de e-mail. Valida e enfileira; nunca aguarda a + * entrega. Erros de validação (bug do caller) lançam `AppError.validation`; + * falha de enqueue (ex.: Valkey down) é logada e engolida (EMAIL-05/10). + */ +export class EmailService { + async send({ template, to, data }: SendEmailInput): Promise { + if (!EMAIL_REGEX.test(to)) { + throw AppError.validation(`E-mail de destino inválido: ${to}`); + } + + if (!isTemplate(template)) { + throw AppError.validation(`Template de e-mail desconhecido: ${template}`); + } + + try { + await enqueueEmail({ template, to, data }); + } catch (error) { + logError("Falha ao enfileirar e-mail.", { + template, + to, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Açúcar sobre `send` para o e-mail de boas-vindas. Injeta `appUrl` a partir + * de `FRONTEND_URL` (config.frontendUrl) para o botão "Acessar plataforma". + */ + async sendWelcome({ + email, + name, + }: { + email: string; + name: string; + }): Promise { + const { frontendUrl } = getConfig(); + + await this.send({ + template: "welcome", + to: email, + data: { name, appUrl: frontendUrl }, + }); + } +} + +/** + * Instância compartilhada para uso pelos callers internos. + */ +export const emailService = new EmailService(); diff --git a/backend/src/modules/email/email.worker.ts b/backend/src/modules/email/email.worker.ts new file mode 100644 index 0000000..125884e --- /dev/null +++ b/backend/src/modules/email/email.worker.ts @@ -0,0 +1,59 @@ +import { type Job, Worker } from "bullmq"; +import { logError, logInfo } from "../../logger"; +import { + EMAIL_QUEUE_NAME, + type EmailJobData, + getEmailConnection, +} from "./email.queue"; +import { getMailProvider } from "./providers/mail-provider"; +import { renderTemplate } from "./templates/registry"; + +let _worker: Worker | null = null; + +/** + * Processa um job: renderiza o template e despacha via provider. + * Um erro do provider é propagado para que o BullMQ re-tente (EMAIL-02/03). + */ +async function processEmailJob(job: Job): Promise { + const { template, to, data } = job.data; + + const { subject, html } = await renderTemplate( + template, + data as never, + ); + + await getMailProvider().send({ to, subject, html }); +} + +/** + * Cria o worker in-process que consome a fila `email`. Chamado no boot. + */ +export function startEmailWorker(): Worker { + if (_worker) return _worker; + + _worker = new Worker(EMAIL_QUEUE_NAME, processEmailJob, { + connection: getEmailConnection(), + }); + + // Fracasso final após esgotar os retries: apenas loga (EMAIL-03). + _worker.on("failed", (job, err) => { + logError("Falha no envio de e-mail após retries.", { + jobId: job?.id, + to: job?.data?.to, + error: err instanceof Error ? err.message : String(err), + }); + }); + + logInfo("Email worker iniciado."); + return _worker; +} + +/** + * Fecha o worker no graceful shutdown. + */ +export async function stopEmailWorker(): Promise { + if (_worker) { + await _worker.close(); + _worker = null; + } +} diff --git a/backend/src/modules/email/providers/mail-provider.ts b/backend/src/modules/email/providers/mail-provider.ts new file mode 100644 index 0000000..d3a4be0 --- /dev/null +++ b/backend/src/modules/email/providers/mail-provider.ts @@ -0,0 +1,31 @@ +import { getConfig } from "../../../config"; +import { NoopProvider } from "./noop.provider"; +import { ResendProvider } from "./resend.provider"; + +/** + * Mensagem já renderizada, pronta para despacho pelo provedor. + */ +export interface MailMessage { + to: string; + subject: string; + html: string; + replyTo?: string; +} + +/** + * Contrato de envio. Desacopla o provedor concreto (Resend, no-op, ...) + * dos callers e do worker. + */ +export interface MailProvider { + send(msg: MailMessage): Promise; +} + +/** + * Seleciona o provedor com base na configuração: + * - `EMAIL_API_KEY` presente => ResendProvider (envia de verdade). + * - `EMAIL_API_KEY` vazio => NoopProvider (apenas loga; EMAIL-09). + */ +export function getMailProvider(): MailProvider { + const { emailApiKey } = getConfig(); + return emailApiKey ? new ResendProvider() : new NoopProvider(); +} diff --git a/backend/src/modules/email/providers/noop.provider.ts b/backend/src/modules/email/providers/noop.provider.ts new file mode 100644 index 0000000..7846837 --- /dev/null +++ b/backend/src/modules/email/providers/noop.provider.ts @@ -0,0 +1,16 @@ +import { logWarn } from "../../../logger"; +import { MailMessage, MailProvider } from "./mail-provider"; + +/** + * Provedor de fallback usado quando nenhuma credencial de e-mail está + * configurada. Loga um aviso e trata o envio como no-op, sem lançar, + * para não derrubar o boot nem o fluxo de negócio (EMAIL-09). + */ +export class NoopProvider implements MailProvider { + async send(msg: MailMessage): Promise { + logWarn("E-mail desabilitado: nenhum provedor configurado.", { + to: msg.to, + subject: msg.subject, + }); + } +} diff --git a/backend/src/modules/email/providers/resend.provider.ts b/backend/src/modules/email/providers/resend.provider.ts new file mode 100644 index 0000000..907a3e0 --- /dev/null +++ b/backend/src/modules/email/providers/resend.provider.ts @@ -0,0 +1,33 @@ +import { Resend } from "resend"; +import { getConfig } from "../../../config"; +import { logError } from "../../../logger"; +import { MailMessage, MailProvider } from "./mail-provider"; + +/** + * Provedor concreto baseado na Resend. Envia o e-mail já renderizado. + * Em caso de falha da API, lança para que o BullMQ re-tente (EMAIL-02/03). + */ +export class ResendProvider implements MailProvider { + async send(msg: MailMessage): Promise { + const { emailApiKey, emailFromAddress, emailFromName } = getConfig(); + const resend = new Resend(emailApiKey); + const from = `${emailFromName} <${emailFromAddress}>`; + + const response = await resend.emails.send({ + from, + to: msg.to, + subject: msg.subject, + html: msg.html, + replyTo: msg.replyTo, + }); + + if (response.error) { + logError("Falha ao enviar e-mail pela Resend.", { + to: msg.to, + subject: msg.subject, + error: response.error.message, + }); + throw new Error(response.error.message); + } + } +} diff --git a/backend/src/modules/email/templates/BaseLayout.tsx b/backend/src/modules/email/templates/BaseLayout.tsx new file mode 100644 index 0000000..4cbe996 --- /dev/null +++ b/backend/src/modules/email/templates/BaseLayout.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; + +interface BaseLayoutProps { + previewText?: string; + children: React.ReactNode; +} + +/** + * Estrutura HTML base compartilhada pelos e-mails transacionais. + * Mantém estilos inline para compatibilidade com clientes de e-mail. + */ +export function BaseLayout({ children }: BaseLayoutProps) { + return ( + + + + + + + + + + + + +
+ {children} +
+ + + ); +} diff --git a/backend/src/modules/email/templates/registry.ts b/backend/src/modules/email/templates/registry.ts new file mode 100644 index 0000000..66f2d78 --- /dev/null +++ b/backend/src/modules/email/templates/registry.ts @@ -0,0 +1,53 @@ +import { render } from "@react-email/render"; +import * as React from "react"; +import { AppError } from "../../../lib/errors"; +import { Welcome, WelcomeProps } from "./welcome"; + +/** + * Mapa de templates disponíveis. Cada entrada define o subject fixo e o + * componente react-email usado para gerar o HTML. + */ +const templates = { + welcome: { + subject: "Bem-vindo ao Candidate", + component: Welcome, + }, +} satisfies Record< + string, + { subject: string; component: (props: never) => React.ReactElement } +>; + +export type TemplateName = keyof typeof templates; + +/** + * Dados aceitos por cada template (props do componente). + */ +export interface TemplateDataMap { + welcome: WelcomeProps; +} + +export function isTemplate(name: string): name is TemplateName { + return Object.prototype.hasOwnProperty.call(templates, name); +} + +/** + * Renderiza o template indicado em HTML. Lança `AppError.validation` quando o + * template não existe (EMAIL-05). O render do react-email é assíncrono. + */ +export async function renderTemplate( + name: T, + data: TemplateDataMap[T], +): Promise<{ subject: string; html: string }> { + if (!isTemplate(name)) { + throw AppError.validation(`Template de e-mail desconhecido: ${name}`); + } + + const entry = templates[name]; + const element = React.createElement( + entry.component as (props: TemplateDataMap[T]) => React.ReactElement, + data, + ); + const html = await render(element); + + return { subject: entry.subject, html }; +} diff --git a/backend/src/modules/email/templates/welcome.tsx b/backend/src/modules/email/templates/welcome.tsx new file mode 100644 index 0000000..9f903c3 --- /dev/null +++ b/backend/src/modules/email/templates/welcome.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; +import { BaseLayout } from "./BaseLayout"; + +export interface WelcomeProps { + name: string; + appUrl: string; +} + +/** + * E-mail de boas-vindas enviado após o registro. Contém o nome do usuário + * e um botão "Acessar plataforma" apontando para o frontend (EMAIL-07). + */ +export function Welcome({ name, appUrl }: WelcomeProps) { + return ( + +

+ Bem-vindo, {name}! +

+

+ Sua conta no Candidate foi criada com sucesso. Agora você pode + acompanhar vagas, salvar oportunidades e organizar sua busca por emprego. +

+ + Acessar plataforma + +
+ ); +} diff --git a/backend/src/modules/users/functions/findOrCreateUser.ts b/backend/src/modules/users/functions/findOrCreateUser.ts index 98752e0..0e009b6 100644 --- a/backend/src/modules/users/functions/findOrCreateUser.ts +++ b/backend/src/modules/users/functions/findOrCreateUser.ts @@ -9,6 +9,16 @@ type FindOrCreateUserParams = { profile: OAuthProfile; }; +/** + * Resultado do `findOrCreateUser`. `isNewUser` distingue o primeiro login + * social (conta recém-criada) de um relogin ou da vinculação de um provider + * a um usuário que já existia — usado para disparar boas-vindas só uma vez. + */ +export type FindOrCreateUserResult = { + user: Awaited>; + isNewUser: boolean; +}; + function mapOAuthProfileToCreateUserParams( profile: OAuthProfile, ): CreateUserParams { @@ -25,14 +35,16 @@ function mapOAuthProfileToCreateUserParams( export async function findOrCreateUser({ provider, profile, -}: FindOrCreateUserParams) { +}: FindOrCreateUserParams): Promise { return db.transaction(async (tx) => { const existingByProvider = await findUserByProvider( { provider, providerAccountId: profile.id }, tx, ); - if (existingByProvider) return existingByProvider; + // Relogin social: conta já existia para este provider. + if (existingByProvider) + return { user: existingByProvider, isNewUser: false }; if (profile.email) { const existingByEmail = await findUserByEmail(profile.email, tx); @@ -47,7 +59,9 @@ export async function findOrCreateUser({ tx, ); - return existingByEmail; + // Usuário já existia (ex.: cadastro por e-mail/senha) — só vincula o + // provider, sem reenviar boas-vindas. + return { user: existingByEmail, isNewUser: false }; } } @@ -66,6 +80,7 @@ export async function findOrCreateUser({ tx, ); - return newUser; + // Primeiro login social: usuário recém-criado. + return { user: newUser, isNewUser: true }; }); } diff --git a/backend/src/server.ts b/backend/src/server.ts index bd0b350..c8705be 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,6 +1,9 @@ import "dotenv/config"; import { createJobsApiApp } from "./app"; -import { logInfo, logWarn } from "./logger"; +import { closeCache } from "./lib/cache"; +import { logError, logInfo, logWarn } from "./logger"; +import { closeEmailQueue } from "./modules/email/email.queue"; +import { startEmailWorker, stopEmailWorker } from "./modules/email/email.worker"; const PORT = Number(process.env.PORT ?? 3001); @@ -22,10 +25,31 @@ async function registerSwaggerDocs(): Promise { async function startServer(): Promise { await registerSwaggerDocs(); + // Worker in-process de e-mail: sobe junto do servidor (EMAIL-02). + startEmailWorker(); + app.listen(PORT, () => { logInfo(`API rodando em http://localhost:${PORT}`); logInfo(`Documentação da API em http://localhost:${PORT}/docs`); }); } +async function shutdown(signal: string): Promise { + logInfo(`Recebido ${signal}, encerrando recursos...`); + try { + await stopEmailWorker(); + await closeEmailQueue(); + await closeCache(); + } catch (error) { + logError("Erro no graceful shutdown", { + error: error instanceof Error ? error.message : error, + }); + } finally { + process.exit(0); + } +} + +process.on("SIGTERM", () => void shutdown("SIGTERM")); +process.on("SIGINT", () => void shutdown("SIGINT")); + void startServer(); diff --git a/backend/tests/unit/adapters/TP-01.goScraper.test.ts b/backend/tests/unit/adapters/TP-01.goScraper.test.ts new file mode 100644 index 0000000..e4652c8 --- /dev/null +++ b/backend/tests/unit/adapters/TP-01.goScraper.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, vi } from "vitest"; +import { TC01 } from "./goScrapper/goScraper.TC01.ts"; +import { TC02 } from "./goScrapper/goScraper.TC02.ts"; +import { TC03 } from "./goScrapper/goScraper.TC03.ts"; +import { TC04 } from "./goScrapper/goScraper.TC04.ts"; +import { TC05 } from "./goScrapper/goScraper.TC05.ts"; +import { TC06 } from "./goScrapper/goScraper.TC06.ts"; + +const mocks = vi.hoisted(() => ({ + logWarn: vi.fn(), + fetch: vi.fn(), +})); + +vi.mock("../../../src/logger.ts", () => ({ + logWarn: mocks.logWarn, +})); + +import { searchJobs } from "../../../src/adapters/goScraper.ts"; + +const validParams = { + keywords: ["Java", "Node.js"], + location: "Brasil", +}; + +const validResponse = { + jobs: [ + { + id: "1", + title: "Dev", + company: "ACME", + location: "Brasil", + url: "https://example.com/job/1", + source: "LinkedIn", + }, + ], + total: 1, + cachedAt: "2026-01-01T00:00:00Z", + fromCache: false, +}; + +describe("goScraper", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", mocks.fetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + TC01({ searchJobs, validParams, validResponse, mocks }); + TC02({ searchJobs, validParams, mocks }); + TC03({ searchJobs, validParams, mocks }); + TC04({ searchJobs, mocks }); + TC05({ searchJobs, validResponse, mocks }); + TC06({ validParams, validResponse, mocks }); +}); diff --git a/backend/tests/unit/adapters/goScraper.test.ts b/backend/tests/unit/adapters/goScraper.test.ts deleted file mode 100644 index 8b474a1..0000000 --- a/backend/tests/unit/adapters/goScraper.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - logWarn: vi.fn(), - fetch: vi.fn(), -})); - -vi.mock("../../../src/logger.ts", () => ({ - logWarn: mocks.logWarn, -})); - -import { searchJobs } from "../../../src/adapters/goScraper.ts"; - -const validParams = { - keywords: ["Java", "Node.js"], - location: "Brasil", -}; - -const validResponse = { - jobs: [ - { - id: "1", - title: "Dev", - company: "ACME", - location: "Brasil", - url: "https://example.com/job/1", - source: "LinkedIn", - }, - ], - total: 1, - cachedAt: "2026-01-01T00:00:00Z", - fromCache: false, -}; - -describe("goScraper", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.stubGlobal("fetch", mocks.fetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("returns jobs when response is valid", async () => { - mocks.fetch.mockResolvedValueOnce({ - ok: true, - json: async () => validResponse, - }); - - const result = await searchJobs(validParams); - - expect(result.total).toBe(1); - expect(result.jobs[0].title).toBe("Dev"); - expect(mocks.fetch).toHaveBeenCalledWith( - expect.stringContaining("/scrape"), - expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - }), - ); - }); - - it("throws when response is not ok", async () => { - mocks.fetch.mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error", - }); - - await expect(searchJobs(validParams)).rejects.toThrow( - "Go scraper: 500 Internal Server Error", - ); - }); - - it("throws when response does not match schema", async () => { - mocks.fetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ invalid: "data" }), - }); - - await expect(searchJobs(validParams)).rejects.toThrow( - "Go scraper: resposta invalida", - ); - expect(mocks.logWarn).toHaveBeenCalledWith( - "Go scraper: resposta fora do contrato", - expect.objectContaining({ error: expect.any(String) }), - ); - }); - - it("throws when params are invalid", async () => { - await expect(searchJobs({ keywords: [] })).rejects.toThrow(); - expect(mocks.fetch).not.toHaveBeenCalled(); - }); - - it("sends validated params to Go scraper", async () => { - mocks.fetch.mockResolvedValueOnce({ - ok: true, - json: async () => validResponse, - }); - - await searchJobs({ - keywords: ["Java", " Java ", "Node.js"], - location: "SP", - }); - - const body = JSON.parse(mocks.fetch.mock.calls[0][1].body); - expect(body.keywords).toEqual(["Java", " Java ", "Node.js"]); - expect(body.location).toBe("SP"); - }); - - it("uses GO_SCRAPER_URL from environment", async () => { - process.env.GO_SCRAPER_URL = "http://custom-go:9999"; - vi.resetModules(); - - vi.stubGlobal("fetch", mocks.fetch); - mocks.fetch.mockResolvedValueOnce({ - ok: true, - json: async () => validResponse, - }); - - const { searchJobs: searchJobsFresh } = - await import("../../../src/adapters/goScraper.ts"); - await searchJobsFresh(validParams); - - expect(mocks.fetch).toHaveBeenCalledWith( - "http://custom-go:9999/scrape", - expect.anything(), - ); - - delete process.env.GO_SCRAPER_URL; - vi.resetModules(); - }); -}); diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC.types.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC.types.ts new file mode 100644 index 0000000..ffde4e0 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC.types.ts @@ -0,0 +1,38 @@ +export interface GoScraperJob { + id: string; + title: string; + company: string; + location: string; + url: string; + source: string; +} + +export interface GoScraperValidParams { + keywords: string[]; + location: string; +} + +export interface GoScraperValidResponse { + jobs: GoScraperJob[]; + total: number; + cachedAt: string; + fromCache: boolean; +} + +export type SearchJobs = (params: { + keywords: string[]; + location?: string; +}) => Promise; + +export interface GoScraperMocks { + logWarn: { + mockClear: () => void; + }; + fetch: { + mockResolvedValueOnce: (value: unknown) => void; + mockClear: () => void; + mock: { + calls: unknown[][]; + }; + }; +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts new file mode 100644 index 0000000..6a4aa82 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC01.ts @@ -0,0 +1,36 @@ +import { expect, it } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidParams, + GoScraperValidResponse, + SearchJobs, +} from "./goScraper.TC.types"; + +interface TC01Deps { + searchJobs: SearchJobs; + validParams: GoScraperValidParams; + validResponse: GoScraperValidResponse; + mocks: GoScraperMocks; +} + +export function TC01({ searchJobs, validParams, validResponse, mocks }: TC01Deps) { + it("deve retornar vagas quando o Go Scraper responder com dados válidos", async () => { + mocks.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => validResponse, + }); + + const result = await searchJobs(validParams); + + expect(result.total).toBe(validResponse.total); + expect(result.jobs).toHaveLength(validResponse.jobs.length); + expect(result.jobs[0]).toMatchObject({ + id: validResponse.jobs[0].id, + title: validResponse.jobs[0].title, + company: validResponse.jobs[0].company, + location: validResponse.jobs[0].location, + url: validResponse.jobs[0].url, + source: validResponse.jobs[0].source, + }); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC02.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC02.ts new file mode 100644 index 0000000..c6e15c6 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC02.ts @@ -0,0 +1,26 @@ +import { expect, it } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidParams, + SearchJobs, +} from "./goScraper.TC.types"; + +interface TC02Deps { + searchJobs: SearchJobs; + validParams: GoScraperValidParams; + mocks: GoScraperMocks; +} + +export function TC02({ searchJobs, validParams, mocks }: TC02Deps) { + it("deve falhar quando o Go Scraper responder com status de erro", async () => { + mocks.fetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + await expect(searchJobs(validParams)).rejects.toThrow( + "Go scraper: 500 Internal Server Error", + ); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC03.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC03.ts new file mode 100644 index 0000000..002ee72 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC03.ts @@ -0,0 +1,29 @@ +import { expect, it } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidParams, + SearchJobs, +} from "./goScraper.TC.types"; + +interface TC03Deps { + searchJobs: SearchJobs; + validParams: GoScraperValidParams; + mocks: GoScraperMocks; +} + +export function TC03({ searchJobs, validParams, mocks }: TC03Deps) { + it("deve rejeitar resposta inválida e registrar aviso de contrato", async () => { + mocks.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ invalid: "data" }), + }); + + await expect(searchJobs(validParams)).rejects.toThrow( + "Go scraper: resposta invalida", + ); + expect(mocks.logWarn).toHaveBeenCalledWith( + "Go scraper: resposta fora do contrato", + expect.objectContaining({ error: expect.any(String) }), + ); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC04.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC04.ts new file mode 100644 index 0000000..128004f --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC04.ts @@ -0,0 +1,14 @@ +import { expect, it } from "vitest"; +import type { GoScraperMocks, SearchJobs } from "./goScraper.TC.types"; + +interface TC04Deps { + searchJobs: SearchJobs; + mocks: GoScraperMocks; +} + +export function TC04({ searchJobs, mocks }: TC04Deps) { + it("deve falhar com parâmetros inválidos sem chamar o serviço externo", async () => { + await expect(searchJobs({ keywords: [] })).rejects.toThrow(); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC05.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC05.ts new file mode 100644 index 0000000..47c6e6b --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC05.ts @@ -0,0 +1,31 @@ +import { expect, it } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidResponse, + SearchJobs, +} from "./goScraper.TC.types"; + +interface TC05Deps { + searchJobs: SearchJobs; + validResponse: GoScraperValidResponse; + mocks: GoScraperMocks; +} + +export function TC05({ searchJobs, validResponse, mocks }: TC05Deps) { + it("deve enviar payload válido ao Go Scraper preservando keywords e localização", async () => { + mocks.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => validResponse, + }); + + await searchJobs({ + keywords: ["Java", " Java ", "Node.js"], + location: "SP", + }); + + const firstCall = mocks.fetch.mock.calls[0] as [string, { body: string }]; + const body = JSON.parse(firstCall[1].body); + expect(body.keywords).toEqual(["Java", " Java ", "Node.js"]); + expect(body.location).toBe("SP"); + }); +} diff --git a/backend/tests/unit/adapters/goScrapper/goScraper.TC06.ts b/backend/tests/unit/adapters/goScrapper/goScraper.TC06.ts new file mode 100644 index 0000000..e766a26 --- /dev/null +++ b/backend/tests/unit/adapters/goScrapper/goScraper.TC06.ts @@ -0,0 +1,42 @@ +import { expect, it, vi } from "vitest"; +import type { + GoScraperMocks, + GoScraperValidParams, + GoScraperValidResponse, +} from "./goScraper.TC.types"; + +interface TC06Deps { + validParams: GoScraperValidParams; + validResponse: GoScraperValidResponse; + mocks: GoScraperMocks; +} + +export function TC06({ validParams, validResponse, mocks }: TC06Deps) { + it("deve usar GO_SCRAPER_URL do ambiente ao montar a URL de scrape", async () => { + const previousGoScraperUrl = process.env.GO_SCRAPER_URL; + process.env.GO_SCRAPER_URL = "http://custom-go:9999"; + vi.resetModules(); + + vi.stubGlobal("fetch", mocks.fetch); + mocks.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => validResponse, + }); + + const { searchJobs: searchJobsFresh } = + await import("../../../../src/adapters/goScraper.ts"); + await searchJobsFresh(validParams); + + expect(mocks.fetch).toHaveBeenCalledWith( + "http://custom-go:9999/scrape", + expect.anything(), + ); + + if (previousGoScraperUrl === undefined) { + delete process.env.GO_SCRAPER_URL; + } else { + process.env.GO_SCRAPER_URL = previousGoScraperUrl; + } + vi.resetModules(); + }); +} diff --git a/backend/tests/unit/modules/auth/auth.service.test.ts b/backend/tests/unit/modules/auth/auth.service.test.ts index a379f0e..9382037 100644 --- a/backend/tests/unit/modules/auth/auth.service.test.ts +++ b/backend/tests/unit/modules/auth/auth.service.test.ts @@ -4,6 +4,13 @@ const mocks = vi.hoisted(() => ({ getAuthUrl: vi.fn(), exchangeCode: vi.fn(), findOrCreateUser: vi.fn(), + sendWelcome: vi.fn(), +})); + +vi.mock("../../../../src/modules/email/email.service", () => ({ + emailService: { + sendWelcome: mocks.sendWelcome, + }, })); vi.mock("../../../../src/modules/auth/providers/auth.provider", () => ({ @@ -113,7 +120,51 @@ describe("AuthService", () => { describe("handleCallback", () => { it("returns user and session on success", async () => { mocks.exchangeCode.mockResolvedValueOnce(mockProfile); - mocks.findOrCreateUser.mockResolvedValueOnce(mockUser); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: false, + }); + + const result = await service.handleCallback(validCallbackParams); + + expect(result.user).toEqual(mockUser); + expect(result.session).toEqual({ userId: "uuid-123", role: "user" }); + }); + + it("enfileira boas-vindas quando é o primeiro login social (usuário novo)", async () => { + mocks.exchangeCode.mockResolvedValueOnce(mockProfile); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: true, + }); + + await service.handleCallback(validCallbackParams); + + expect(mocks.sendWelcome).toHaveBeenCalledWith({ + email: mockUser.email, + name: mockUser.username, + }); + }); + + it("não enfileira boas-vindas quando o usuário já existia (relogin/vínculo)", async () => { + mocks.exchangeCode.mockResolvedValueOnce(mockProfile); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: false, + }); + + await service.handleCallback(validCallbackParams); + + expect(mocks.sendWelcome).not.toHaveBeenCalled(); + }); + + it("conclui o login mesmo se o envio de boas-vindas falhar", async () => { + mocks.exchangeCode.mockResolvedValueOnce(mockProfile); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: true, + }); + mocks.sendWelcome.mockRejectedValueOnce(new Error("valkey down")); const result = await service.handleCallback(validCallbackParams); @@ -153,7 +204,10 @@ describe("AuthService", () => { it("calls findOrCreateUser with provider and profile", async () => { mocks.exchangeCode.mockResolvedValueOnce(mockProfile); - mocks.findOrCreateUser.mockResolvedValueOnce(mockUser); + mocks.findOrCreateUser.mockResolvedValueOnce({ + user: mockUser, + isNewUser: false, + }); await service.handleCallback(validCallbackParams); diff --git a/backend/tests/unit/modules/auth/credentials.service.test.ts b/backend/tests/unit/modules/auth/credentials.service.test.ts index 49d7b23..5ff325f 100644 --- a/backend/tests/unit/modules/auth/credentials.service.test.ts +++ b/backend/tests/unit/modules/auth/credentials.service.test.ts @@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({ verify: vi.fn(), // generateUsername generateUsername: vi.fn(), + // email de boas-vindas + sendWelcome: vi.fn(), })); // ─── Mock: database client ──────────────────────────────────────────────────── @@ -53,6 +55,14 @@ vi.mock("../../../../src/utils/generateUsername", () => ({ generateUsername: mocks.generateUsername, })); +// ─── Mock: EmailService (boas-vindas) ───────────────────────────────────────── + +vi.mock("../../../../src/modules/email/email.service", () => ({ + emailService: { + sendWelcome: mocks.sendWelcome, + }, +})); + // ─── Import after mocks ─────────────────────────────────────────────────────── import { CredentialsService } from "../../../../src/modules/auth/credentials.service"; @@ -121,6 +131,7 @@ describe("CredentialsService", () => { mocks.verify.mockResolvedValue(true); mocks.hash.mockResolvedValue("$argon2id$hashed"); mocks.generateUsername.mockResolvedValue("mocked-username"); + mocks.sendWelcome.mockResolvedValue(undefined); service = new CredentialsService(); }); @@ -197,6 +208,24 @@ describe("CredentialsService", () => { expect(session).toEqual({ userId: "uuid-user-1", role: "user" }); }); + + it("dispara e-mail de boas-vindas com email + nome no sucesso (EMAIL-06)", async () => { + await service.register(registerInput); + + expect(mocks.sendWelcome).toHaveBeenCalledWith({ + email: mockUser.email, + name: mockUser.displayName, + }); + }); + + it("conclui o registro mesmo se o envio de boas-vindas falhar (EMAIL-08)", async () => { + mocks.sendWelcome.mockRejectedValue(new Error("fila indisponível")); + + const result = await service.register(registerInput); + + expect(result.user).toMatchObject({ email: mockUser.email }); + expect(result.session).toEqual({ userId: mockUser.id, role: "user" }); + }); }); // ── login ───────────────────────────────────────────────────────────────── diff --git a/backend/tests/unit/modules/email/email.queue.test.ts b/backend/tests/unit/modules/email/email.queue.test.ts new file mode 100644 index 0000000..7f969ab --- /dev/null +++ b/backend/tests/unit/modules/email/email.queue.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const configMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), +})); + +const bullmqMocks = vi.hoisted(() => ({ + add: vi.fn(), + close: vi.fn(), + QueueConstructor: vi.fn(), +})); + +const ioredisMocks = vi.hoisted(() => ({ + RedisConstructor: vi.fn(), + quit: vi.fn(), +})); + +vi.mock("../../../../src/config", () => ({ + getConfig: configMocks.getConfig, +})); + +vi.mock("../../../../src/logger", () => ({ + logError: vi.fn(), + logWarn: vi.fn(), + logInfo: vi.fn(), +})); + +vi.mock("bullmq", () => ({ + Queue: class { + add = bullmqMocks.add; + close = bullmqMocks.close; + constructor(name: string, opts: unknown) { + bullmqMocks.QueueConstructor(name, opts); + } + }, +})); + +vi.mock("ioredis", () => ({ + default: class { + quit = ioredisMocks.quit; + constructor(url: string, opts: unknown) { + ioredisMocks.RedisConstructor(url, opts); + } + }, +})); + +import { + closeEmailQueue, + enqueueEmail, + getEmailQueue, +} from "../../../../src/modules/email/email.queue"; + +describe("EmailQueue", () => { + beforeEach(() => { + vi.clearAllMocks(); + configMocks.getConfig.mockReturnValue({ + valkeyUrl: "redis://localhost:6379", + emailQueueAttempts: 3, + }); + }); + + afterEach(async () => { + await closeEmailQueue(); + }); + + it("cria a conexão ioredis com maxRetriesPerRequest null (EMAIL-01)", () => { + getEmailQueue(); + + expect(ioredisMocks.RedisConstructor).toHaveBeenCalledWith( + "redis://localhost:6379", + { maxRetriesPerRequest: null }, + ); + }); + + it("cria a fila 'email' reutilizando a mesma instância (singleton)", () => { + const first = getEmailQueue(); + const second = getEmailQueue(); + + expect(first).toBe(second); + expect(bullmqMocks.QueueConstructor).toHaveBeenCalledTimes(1); + expect(bullmqMocks.QueueConstructor).toHaveBeenCalledWith( + "email", + expect.objectContaining({ connection: expect.anything() }), + ); + }); + + it("enfileira job com attempts do config e backoff exponencial (EMAIL-03)", async () => { + const data = { template: "welcome", to: "user@example.com", data: {} }; + + await enqueueEmail(data); + + expect(bullmqMocks.add).toHaveBeenCalledWith( + "send-email", + data, + expect.objectContaining({ + attempts: 3, + backoff: { type: "exponential", delay: 2000 }, + }), + ); + }); + + it("usa o valor de emailQueueAttempts do config para attempts", async () => { + configMocks.getConfig.mockReturnValue({ + valkeyUrl: "redis://localhost:6379", + emailQueueAttempts: 5, + }); + + await enqueueEmail({ template: "welcome", to: "a@b.com", data: {} }); + + expect(bullmqMocks.add).toHaveBeenCalledWith( + "send-email", + expect.anything(), + expect.objectContaining({ attempts: 5 }), + ); + }); + + it("fecha a fila e a conexão no closeEmailQueue", async () => { + getEmailQueue(); + + await closeEmailQueue(); + + expect(bullmqMocks.close).toHaveBeenCalled(); + expect(ioredisMocks.quit).toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/unit/modules/email/email.service.test.ts b/backend/tests/unit/modules/email/email.service.test.ts new file mode 100644 index 0000000..22703fb --- /dev/null +++ b/backend/tests/unit/modules/email/email.service.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const queueMocks = vi.hoisted(() => ({ + enqueueEmail: vi.fn(), +})); + +const registryMocks = vi.hoisted(() => ({ + isTemplate: vi.fn(), +})); + +const configMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), +})); + +const loggerMocks = vi.hoisted(() => ({ + logError: vi.fn(), + logInfo: vi.fn(), + logWarn: vi.fn(), +})); + +vi.mock("../../../../src/modules/email/email.queue", () => ({ + enqueueEmail: queueMocks.enqueueEmail, +})); + +vi.mock("../../../../src/modules/email/templates/registry", () => ({ + isTemplate: registryMocks.isTemplate, +})); + +vi.mock("../../../../src/config", () => ({ + getConfig: configMocks.getConfig, +})); + +vi.mock("../../../../src/logger", () => loggerMocks); + +import { EmailService } from "../../../../src/modules/email/email.service"; + +describe("EmailService", () => { + let service: EmailService; + + beforeEach(() => { + vi.clearAllMocks(); + registryMocks.isTemplate.mockReturnValue(true); + queueMocks.enqueueEmail.mockResolvedValue(undefined); + configMocks.getConfig.mockReturnValue({ + frontendUrl: "https://painelvagas.com", + }); + service = new EmailService(); + }); + + describe("send", () => { + it("enfileira job com { template, to, data } no caminho feliz (EMAIL-01)", async () => { + await service.send({ + template: "welcome", + to: "user@example.com", + data: { name: "Ana" }, + }); + + expect(queueMocks.enqueueEmail).toHaveBeenCalledWith({ + template: "welcome", + to: "user@example.com", + data: { name: "Ana" }, + }); + }); + + it("lança AppError.validation e NÃO enfileira quando 'to' é inválido (EMAIL-05)", async () => { + await expect( + service.send({ + template: "welcome", + to: "nao-e-email", + data: {}, + }), + ).rejects.toMatchObject({ code: "VALIDATION_ERROR" }); + + expect(queueMocks.enqueueEmail).not.toHaveBeenCalled(); + }); + + it("lança AppError.validation e NÃO enfileira quando template é desconhecido (EMAIL-05)", async () => { + registryMocks.isTemplate.mockReturnValue(false); + + await expect( + service.send({ + template: "inexistente" as never, + to: "user@example.com", + data: {}, + }), + ).rejects.toMatchObject({ code: "VALIDATION_ERROR" }); + + expect(queueMocks.enqueueEmail).not.toHaveBeenCalled(); + }); + + it("engole falha do enqueue: send resolve e loga o erro (EMAIL-10)", async () => { + queueMocks.enqueueEmail.mockRejectedValue(new Error("valkey down")); + + await expect( + service.send({ + template: "welcome", + to: "user@example.com", + data: {}, + }), + ).resolves.toBeUndefined(); + + expect(loggerMocks.logError).toHaveBeenCalled(); + }); + }); + + describe("sendWelcome", () => { + it("injeta appUrl do frontendUrl e usa template welcome (EMAIL-06/07)", async () => { + await service.sendWelcome({ + email: "user@example.com", + name: "Ana", + }); + + expect(queueMocks.enqueueEmail).toHaveBeenCalledWith({ + template: "welcome", + to: "user@example.com", + data: { name: "Ana", appUrl: "https://painelvagas.com" }, + }); + }); + }); +}); diff --git a/backend/tests/unit/modules/email/email.worker.test.ts b/backend/tests/unit/modules/email/email.worker.test.ts new file mode 100644 index 0000000..35e6dc5 --- /dev/null +++ b/backend/tests/unit/modules/email/email.worker.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const registryMocks = vi.hoisted(() => ({ + renderTemplate: vi.fn(), +})); + +const providerMocks = vi.hoisted(() => ({ + send: vi.fn(), + getMailProvider: vi.fn(), +})); + +const queueMocks = vi.hoisted(() => ({ + getEmailConnection: vi.fn(() => ({})), +})); + +const loggerMocks = vi.hoisted(() => ({ + logError: vi.fn(), + logInfo: vi.fn(), + logWarn: vi.fn(), +})); + +// Captura o processor e os handlers registrados no Worker. +const workerState = vi.hoisted(() => ({ + processor: null as + | ((job: { data: unknown }) => Promise) + | null, + handlers: {} as Record void>, + close: vi.fn(), + on: vi.fn(), +})); + +vi.mock("bullmq", () => ({ + Worker: class { + close = workerState.close; + constructor( + _name: string, + processor: (job: { data: unknown }) => Promise, + ) { + workerState.processor = processor; + } + on(event: string, handler: (...args: unknown[]) => void) { + workerState.handlers[event] = handler; + workerState.on(event, handler); + return this; + } + }, +})); + +vi.mock("../../../../src/modules/email/email.queue", () => ({ + EMAIL_QUEUE_NAME: "email", + getEmailConnection: queueMocks.getEmailConnection, +})); + +vi.mock("../../../../src/modules/email/templates/registry", () => ({ + renderTemplate: registryMocks.renderTemplate, +})); + +vi.mock("../../../../src/modules/email/providers/mail-provider", () => ({ + getMailProvider: providerMocks.getMailProvider, +})); + +vi.mock("../../../../src/logger", () => loggerMocks); + +import { + startEmailWorker, + stopEmailWorker, +} from "../../../../src/modules/email/email.worker"; + +describe("EmailWorker", () => { + beforeEach(() => { + vi.clearAllMocks(); + workerState.processor = null; + workerState.handlers = {}; + providerMocks.getMailProvider.mockReturnValue({ send: providerMocks.send }); + registryMocks.renderTemplate.mockResolvedValue({ + subject: "Bem-vindo", + html: "

Olá

", + }); + providerMocks.send.mockResolvedValue(undefined); + }); + + afterEach(async () => { + await stopEmailWorker(); + }); + + it("renderiza o template e despacha via provider com to/subject/html (EMAIL-02)", async () => { + startEmailWorker(); + const job = { + data: { template: "welcome", to: "user@example.com", data: { name: "Ana" } }, + }; + + await workerState.processor!(job); + + expect(registryMocks.renderTemplate).toHaveBeenCalledWith("welcome", { + name: "Ana", + }); + expect(providerMocks.send).toHaveBeenCalledWith({ + to: "user@example.com", + subject: "Bem-vindo", + html: "

Olá

", + }); + }); + + it("propaga o erro do provider para permitir retry do BullMQ (EMAIL-03)", async () => { + providerMocks.send.mockRejectedValue(new Error("provider down")); + startEmailWorker(); + const job = { + data: { template: "welcome", to: "user@example.com", data: {} }, + }; + + await expect(workerState.processor!(job)).rejects.toThrow("provider down"); + }); + + it("registra o erro no handler 'failed' sem lançar (EMAIL-03)", () => { + startEmailWorker(); + const failedHandler = workerState.handlers["failed"]; + expect(failedHandler).toBeDefined(); + + const job = { id: "job-1", data: { to: "user@example.com" } }; + expect(() => + failedHandler(job, new Error("fracasso final")), + ).not.toThrow(); + expect(loggerMocks.logError).toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/unit/modules/email/mail-provider.test.ts b/backend/tests/unit/modules/email/mail-provider.test.ts new file mode 100644 index 0000000..1eb4b63 --- /dev/null +++ b/backend/tests/unit/modules/email/mail-provider.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const configMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), +})); + +// Providers concretos mockados (definidos em vi.hoisted para poder ser +// referenciados dentro das factories de vi.mock, que são içadas ao topo). +const providerMocks = vi.hoisted(() => ({ + FakeResend: class FakeResend {}, + FakeNoop: class FakeNoop {}, +})); +const { FakeResend, FakeNoop } = providerMocks; + +vi.mock("../../../../src/config", () => ({ + getConfig: configMocks.getConfig, +})); + +vi.mock("../../../../src/modules/email/providers/resend.provider", () => ({ + ResendProvider: providerMocks.FakeResend, +})); + +vi.mock("../../../../src/modules/email/providers/noop.provider", () => ({ + NoopProvider: providerMocks.FakeNoop, +})); + +import { getMailProvider } from "../../../../src/modules/email/providers/mail-provider"; + +function baseConfig(overrides: Record = {}) { + return { + emailApiKey: "", + emailFromAddress: "no-reply@example.com", + emailFromName: "Painel Vagas", + ...overrides, + }; +} + +describe("getMailProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("retorna NoopProvider quando emailApiKey está vazio (EMAIL-09)", () => { + configMocks.getConfig.mockReturnValue(baseConfig({ emailApiKey: "" })); + + const provider = getMailProvider(); + + expect(provider).toBeInstanceOf(FakeNoop); + }); + + it("retorna ResendProvider quando emailApiKey está presente (EMAIL-04)", () => { + configMocks.getConfig.mockReturnValue( + baseConfig({ emailApiKey: "re_abc123" }), + ); + + const provider = getMailProvider(); + + expect(provider).toBeInstanceOf(FakeResend); + }); +}); diff --git a/backend/tests/unit/modules/email/noop.provider.test.ts b/backend/tests/unit/modules/email/noop.provider.test.ts new file mode 100644 index 0000000..0b5b5ad --- /dev/null +++ b/backend/tests/unit/modules/email/noop.provider.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loggerMocks = vi.hoisted(() => ({ + logWarn: vi.fn(), +})); + +vi.mock("../../../../src/logger", () => ({ + logWarn: loggerMocks.logWarn, + logInfo: vi.fn(), + logError: vi.fn(), +})); + +import { NoopProvider } from "../../../../src/modules/email/providers/noop.provider"; + +describe("NoopProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loga aviso e resolve sem lançar (EMAIL-09)", async () => { + const provider = new NoopProvider(); + + await expect( + provider.send({ + to: "user@example.com", + subject: "Assunto", + html: "

oi

", + }), + ).resolves.toBeUndefined(); + + expect(loggerMocks.logWarn).toHaveBeenCalledOnce(); + const [, ctx] = loggerMocks.logWarn.mock.calls[0]; + expect(ctx).toMatchObject({ to: "user@example.com" }); + }); +}); diff --git a/backend/tests/unit/modules/email/registry.test.ts b/backend/tests/unit/modules/email/registry.test.ts new file mode 100644 index 0000000..10a8e10 --- /dev/null +++ b/backend/tests/unit/modules/email/registry.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + isTemplate, + renderTemplate, +} from "../../../../src/modules/email/templates/registry"; + +describe("TemplateRegistry", () => { + describe("isTemplate", () => { + it("reconhece 'welcome' como template válido", () => { + expect(isTemplate("welcome")).toBe(true); + }); + + it("rejeita nome desconhecido (EMAIL-05)", () => { + expect(isTemplate("desconhecido")).toBe(false); + }); + }); + + describe("renderTemplate", () => { + it("renderiza 'welcome' com nome, CTA apontando para appUrl e subject (EMAIL-07)", async () => { + const appUrl = "https://app.example.com"; + const { subject, html } = await renderTemplate("welcome", { + name: "Maria", + appUrl, + }); + + expect(subject).toBeTruthy(); + expect(typeof subject).toBe("string"); + expect(html).toContain("Maria"); + expect(html).toContain(`href="${appUrl}"`); + expect(html).toContain("Acessar plataforma"); + }); + + it("rejeita template desconhecido (EMAIL-05)", async () => { + await expect( + // @ts-expect-error nome inválido é rejeitado em runtime + renderTemplate("desconhecido", { name: "X", appUrl: "https://x" }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/backend/tests/unit/modules/email/resend.provider.test.ts b/backend/tests/unit/modules/email/resend.provider.test.ts new file mode 100644 index 0000000..35774f8 --- /dev/null +++ b/backend/tests/unit/modules/email/resend.provider.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const configMocks = vi.hoisted(() => ({ + getConfig: vi.fn(), +})); + +const resendMocks = vi.hoisted(() => ({ + send: vi.fn(), + constructor: vi.fn(), +})); + +vi.mock("../../../../src/config", () => ({ + getConfig: configMocks.getConfig, +})); + +vi.mock("resend", () => ({ + Resend: class { + emails = { send: resendMocks.send }; + constructor(apiKey: string) { + resendMocks.constructor(apiKey); + } + }, +})); + +vi.mock("../../../../src/logger", () => ({ + logError: vi.fn(), + logWarn: vi.fn(), + logInfo: vi.fn(), +})); + +import { ResendProvider } from "../../../../src/modules/email/providers/resend.provider"; + +describe("ResendProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + configMocks.getConfig.mockReturnValue({ + emailApiKey: "re_abc123", + emailFromAddress: "no-reply@painelvagas.com", + emailFromName: "Painel Vagas", + }); + }); + + it("chama o SDK com from formatado e campos mapeados (EMAIL-02/04)", async () => { + resendMocks.send.mockResolvedValue({ data: { id: "eml_1" }, error: null }); + const provider = new ResendProvider(); + + await provider.send({ + to: "user@example.com", + subject: "Bem-vindo", + html: "

Olá

", + replyTo: "suporte@painelvagas.com", + }); + + expect(resendMocks.constructor).toHaveBeenCalledWith("re_abc123"); + expect(resendMocks.send).toHaveBeenCalledWith({ + from: "Painel Vagas ", + to: "user@example.com", + subject: "Bem-vindo", + html: "

Olá

", + replyTo: "suporte@painelvagas.com", + }); + }); + + it("propaga (lança) quando o SDK retorna erro para permitir retry (EMAIL-03)", async () => { + resendMocks.send.mockResolvedValue({ + data: null, + error: { message: "rate limit", name: "rate_limit_exceeded" }, + }); + const provider = new ResendProvider(); + + await expect( + provider.send({ + to: "user@example.com", + subject: "Bem-vindo", + html: "

Olá

", + }), + ).rejects.toThrow("rate limit"); + }); +}); diff --git a/backend/tests/unit/modules/users/functions/users.functions.test.ts b/backend/tests/unit/modules/users/functions/users.functions.test.ts index 1e82663..9877215 100644 --- a/backend/tests/unit/modules/users/functions/users.functions.test.ts +++ b/backend/tests/unit/modules/users/functions/users.functions.test.ts @@ -138,7 +138,7 @@ describe("findOrCreateUser", () => { profile: oauthProfile, }); - expect(result).toMatchObject(mockUser); + expect(result).toEqual({ user: mockUser, isNewUser: false }); }); it("encontra por email, cria account e retorna usuário existente", async () => { @@ -152,7 +152,7 @@ describe("findOrCreateUser", () => { profile: oauthProfile, }); - expect(result).toMatchObject(mockUser); + expect(result).toEqual({ user: mockUser, isNewUser: false }); expect(mocks.createAccountMock).toHaveBeenCalledWith( { userId: mockUser.id, provider: "github", profile: oauthProfile }, expect.anything(), @@ -187,7 +187,7 @@ describe("findOrCreateUser", () => { { userId: mockUser.id, provider: "github", profile: oauthProfile }, expect.anything(), ); - expect(result).toMatchObject(mockUser); + expect(result).toEqual({ user: mockUser, isNewUser: true }); }); it("pula a busca por email quando profile.email é undefined", async () => { diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 368c555..84e28c4 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -6,6 +6,7 @@ "outDir": "./dist", "rootDir": "./src", "strict": true, + "jsx": "react-jsx", "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true diff --git a/backend/vitest.config.js b/backend/vitest.config.js index 9730815..1091c47 100644 --- a/backend/vitest.config.js +++ b/backend/vitest.config.js @@ -22,6 +22,19 @@ export default defineConfig(({ mode }) => { const env = loadTestEnv(mode ?? "test"); return { + // Habilita o runtime automático de JSX (react-email templates .tsx). + // O backend usa rolldown-vite (transform via oxc); mantemos também a + // chave esbuild para compatibilidade caso o vite padrão seja usado. + esbuild: { + jsx: "automatic", + jsxDev: false, + }, + oxc: { + jsx: { + runtime: "automatic", + development: false, + }, + }, test: { globals: true, environment: "node", diff --git a/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx b/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx index ab5b951..3f2245a 100644 --- a/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx +++ b/frontend/src/domains/auth/presentation/components/RegisterFormPanel.tsx @@ -1,11 +1,9 @@ +import { useTheme } from "@/shared/hooks/useTheme"; +import { ThemeToggle } from "@/shared/ui/theme-toggle"; import { Image } from "@unpic/react"; import { motion } from "framer-motion"; import { ArrowLeft, Eye, EyeOff } from "lucide-react"; import { FormEvent, useState } from "react"; -import PhoneInput from "react-phone-number-input"; -import "react-phone-number-input/style.css"; -import { ThemeToggle } from "@/shared/ui/theme-toggle"; -import { useTheme } from "@/shared/hooks/useTheme"; import { getGithubAuthUrl, @@ -36,11 +34,136 @@ const LEVEL_OPTIONS = [ const REGISTER_LIMITS = { name: 100, email: 254, - phoneDigits: 15, + phoneDigitsWithCountryCode: 13, + phoneDigitsMobileWithoutCountryCode: 11, + phoneDigitsLandlineWithoutCountryCode: 10, + phoneInput: 19, password: 128, cpf: 14, } as const; +const PHONE_ALLOWED_CHARS_REGEX = /^[0-9+()\s-]*$/; +const BRAZIL_MOBILE_PHONE_REGEX = /^(?:\+55)?[1-9]{2}9\d{8}$/; +const BRAZIL_LANDLINE_PHONE_REGEX = /^(?:\+55)?[1-9]{2}[2-8]\d{7}$/; + +function formatBrazilianPhoneFromDigits(digits: string) { + const ddd = digits.slice(0, 2); + const subscriber = digits.slice(2); + + if (digits.length <= 2) return `(${ddd}`; + + const isMobile = digits.length === 11 || subscriber.startsWith("9"); + if (isMobile) { + if (subscriber.length <= 5) return `(${ddd}) ${subscriber}`; + return `(${ddd}) ${subscriber.slice(0, 5)}-${subscriber.slice(5, 9)}`; + } + + if (subscriber.length <= 4) return `(${ddd}) ${subscriber}`; + return `(${ddd}) ${subscriber.slice(0, 4)}-${subscriber.slice(4, 8)}`; +} + +function normalizePhoneInput(rawValue: string) { + const trimmed = rawValue.trim(); + const digits = trimmed.replace(/\D/g, ""); + const hasExplicitPlus = trimmed.startsWith("+"); + + if (hasExplicitPlus && !digits.startsWith("55")) { + return { + masked: `+${digits.slice(0, REGISTER_LIMITS.phoneDigitsWithCountryCode)}`, + }; + } + + const inferredCountryCode = + hasExplicitPlus || (digits.startsWith("55") && digits.length > 11); + + let nationalDigits = inferredCountryCode ? digits.slice(2) : digits; + const defaultMaxDigits = + nationalDigits[2] === "9" + ? REGISTER_LIMITS.phoneDigitsMobileWithoutCountryCode + : REGISTER_LIMITS.phoneDigitsLandlineWithoutCountryCode; + + const maxNationalDigits = Math.min( + defaultMaxDigits, + REGISTER_LIMITS.phoneDigitsMobileWithoutCountryCode, + ); + + nationalDigits = nationalDigits.slice(0, maxNationalDigits); + const maskedNational = nationalDigits + ? formatBrazilianPhoneFromDigits(nationalDigits) + : ""; + + return { + masked: inferredCountryCode + ? `+55${maskedNational ? ` ${maskedNational}` : ""}` + : maskedNational, + }; +} + +function sanitizePhoneForValidation(value: string) { + const trimmed = value.trim(); + const plusCount = (trimmed.match(/\+/g) || []).length; + + if (!PHONE_ALLOWED_CHARS_REGEX.test(trimmed)) { + return { valid: false as const }; + } + + if (plusCount > 1 || (plusCount === 1 && !trimmed.startsWith("+"))) { + return { valid: false as const }; + } + + const compact = trimmed.replace(/[\s()-]/g, ""); + if (/[^\d+]/.test(compact)) { + return { valid: false as const }; + } + + const hasCountryCode = compact.startsWith("+"); + if (hasCountryCode && !compact.startsWith("+55")) { + return { valid: false as const }; + } + + if (hasCountryCode && !/^\+55\d+$/.test(compact)) { + return { valid: false as const }; + } + + const digitsOnly = compact.replace(/\D/g, ""); + const nationalDigits = hasCountryCode ? digitsOnly.slice(2) : digitsOnly; + const isMobileLength = + nationalDigits.length === REGISTER_LIMITS.phoneDigitsMobileWithoutCountryCode; + const isLandlineLength = + nationalDigits.length === + REGISTER_LIMITS.phoneDigitsLandlineWithoutCountryCode; + + if (!isMobileLength && !isLandlineLength) { + return { valid: false as const }; + } + + if ( + hasCountryCode && + digitsOnly.length !== REGISTER_LIMITS.phoneDigitsWithCountryCode && + digitsOnly.length !== REGISTER_LIMITS.phoneDigitsWithCountryCode - 1 + ) { + return { valid: false as const }; + } + + const ddd = nationalDigits.slice(0, 2); + if (!/^[1-9]{2}$/.test(ddd)) { + return { valid: false as const }; + } + + const candidate = hasCountryCode ? `+55${nationalDigits}` : nationalDigits; + const isValidMobile = BRAZIL_MOBILE_PHONE_REGEX.test(candidate); + const isValidLandline = BRAZIL_LANDLINE_PHONE_REGEX.test(candidate); + + if (!isValidMobile && !isValidLandline) { + return { valid: false as const }; + } + + return { + valid: true as const, + payload: hasCountryCode ? `+55${nationalDigits}` : nationalDigits, + }; +} + function getErrorMessage(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } @@ -131,6 +254,34 @@ export default function RegisterSide() { const handleRevealPassword = () => setShowPassword((prev) => !prev); + const handlePhoneChange = (rawValue: string) => { + const typed = rawValue ?? ""; + if (typed && !PHONE_ALLOWED_CHARS_REGEX.test(typed)) { + return; + } + + const digits = typed.replace(/\D/g, ""); + const trimmed = typed.trim(); + const hasCountryCode = + trimmed.startsWith("+") || (digits.startsWith("55") && digits.length > 11); + const nationalDigits = hasCountryCode ? digits.slice(2) : digits; + const maxNationalDigits = + nationalDigits[2] === "9" + ? REGISTER_LIMITS.phoneDigitsMobileWithoutCountryCode + : REGISTER_LIMITS.phoneDigitsLandlineWithoutCountryCode; + const maxTotalDigits = hasCountryCode ? 2 + maxNationalDigits : maxNationalDigits; + + if (digits.length > maxTotalDigits) { + return; + } + + const normalized = normalizePhoneInput(typed); + setTelefone(normalized.masked); + if (telefoneError) { + setTelefoneError(""); + } + }; + const formatCpf = (value: string) => { const digits = value.replace(/\D/g, "").slice(0, 11); if (digits.length <= 3) return digits; @@ -168,9 +319,16 @@ export default function RegisterSide() { } } - if (!telefone) { - setTelefoneError("O campo de telefone é obrigatório."); - isValid = false; + const normalizedPhone = (telefone ?? "").trim(); + let phoneToSend: string | undefined; + if (normalizedPhone) { + const parsedPhone = sanitizePhoneForValidation(normalizedPhone); + if (!parsedPhone.valid) { + setTelefoneError("Informe um telefone brasileiro válido."); + isValid = false; + } else { + phoneToSend = parsedPhone.payload; + } } if (!password) { @@ -202,7 +360,7 @@ export default function RegisterSide() { email: email, password: password, name: nome, - phone: telefone, + phone: phoneToSend, cpf: cpf || undefined, level, }); @@ -305,26 +463,21 @@ export default function RegisterSide() {
- { - if ( - !value || - value.replace(/\D/g, "").length <= REGISTER_LIMITS.phoneDigits - ) { - setTelefone(value); - } - }} - numberInputProps={{ maxLength: REGISTER_LIMITS.phoneDigits + 1 }} + handlePhoneChange(e.target.value)} + maxLength={REGISTER_LIMITS.phoneInput} disabled={isLoading} - className="w-full px-4 py-3.5 text-gray-900 dark:text-white bg-transparent focus:outline-none phone-input-custom" + className="w-full px-4 py-3.5 text-gray-900 dark:text-white bg-transparent focus:outline-none" placeholder="(34) 23456-7890" />
diff --git a/frontend/tests/unit/components/login/RegisterSide.test.tsx b/frontend/tests/unit/components/login/RegisterSide.test.tsx index 0486826..ab87594 100644 --- a/frontend/tests/unit/components/login/RegisterSide.test.tsx +++ b/frontend/tests/unit/components/login/RegisterSide.test.tsx @@ -26,6 +26,21 @@ function fillRequiredRegisterFields() { }); } +function fillRequiredRegisterFieldsWithoutPhone() { + fireEvent.change(screen.getByLabelText(/nome/i), { + target: { value: "Bene" }, + }); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: "bene@teste.com" }, + }); + fireEvent.change(screen.getByLabelText(/senha/i), { + target: { value: "12345678" }, + }); + fireEvent.change(screen.getByLabelText(/nível de experiência/i), { + target: { value: "pleno" }, + }); +} + vi.mock("@/domains/auth/infrastructure/authApi", () => ({ register: (...args: any[]) => mockRegister(...args), getGoogleAuthUrl: (...args: any[]) => mockGetGoogleAuthUrl(...args), @@ -110,7 +125,7 @@ describe("RegisterSide", () => { expect(screen.getByLabelText(/email/i)).toHaveAttribute("maxlength", "254"); expect(screen.getByPlaceholderText(/\(34\)/i)).toHaveAttribute( "maxlength", - "16", + "19", ); expect(screen.getByLabelText(/senha/i)).toHaveAttribute("maxlength", "128"); expect(screen.getByLabelText(/cpf/i)).toHaveAttribute("maxlength", "14"); @@ -139,9 +154,6 @@ describe("RegisterSide", () => { expect( await screen.findByText(/campo de e-mail é obrigatório/i), ).toBeInTheDocument(); - expect( - await screen.findByText(/campo de telefone é obrigatório/i), - ).toBeInTheDocument(); expect( await screen.findByText(/campo de senha é obrigatório/i), ).toBeInTheDocument(); @@ -190,6 +202,69 @@ describe("RegisterSide", () => { expect(window.location.href).toBe("/login?registered=true"); }); + it("envia formulário válido sem telefone", async () => { + mockRegister.mockResolvedValueOnce({ message: "Usuário criado" }); + render(); + fillRequiredRegisterFieldsWithoutPhone(); + fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); + + await waitFor(() => { + expect(mockRegister).toHaveBeenCalledWith({ + email: "bene@teste.com", + password: "12345678", + name: "Bene", + phone: undefined, + cpf: undefined, + level: "pleno", + }); + }); + }); + + it("rejeita telefone inválido quando preenchido", async () => { + render(); + fillRequiredRegisterFieldsWithoutPhone(); + fireEvent.change(screen.getByPlaceholderText(/\(34\)/i), { + target: { value: "123" }, + }); + fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); + + expect( + await screen.findByText(/telefone brasileiro válido/i), + ).toBeInTheDocument(); + expect(mockRegister).not.toHaveBeenCalled(); + }); + + it("aplica máscara para celular e fixo brasileiros", () => { + render(); + const phoneInput = screen.getByPlaceholderText(/\(34\)/i) as HTMLInputElement; + + fireEvent.change(phoneInput, { target: { value: "11912345678" } }); + expect(phoneInput.value).toBe("(11) 91234-5678"); + + fireEvent.change(phoneInput, { target: { value: "1134567890" } }); + expect(phoneInput.value).toBe("(11) 3456-7890"); + + fireEvent.change(phoneInput, { target: { value: "5511912345678" } }); + expect(phoneInput.value).toBe("+55 (11) 91234-5678"); + }); + + it("bloqueia números maiores que o limite permitido", async () => { + render(); + fillRequiredRegisterFieldsWithoutPhone(); + const phoneInput = screen.getByPlaceholderText(/\(34\)/i) as HTMLInputElement; + + fireEvent.change(phoneInput, { target: { value: "+55 1891898989989999" } }); + expect(phoneInput.value).toBe(""); + + fireEvent.click(screen.getByRole("button", { name: /cadastrar/i })); + + await waitFor(() => { + expect(mockRegister).toHaveBeenCalledWith( + expect.objectContaining({ phone: undefined }), + ); + }); + }); + it("envia formulário válido com CPF como usuário (sem tecnologias/nível)", async () => { mockRegister.mockResolvedValueOnce({ message: "Usuário criado" }); render(); diff --git a/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx b/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx index 765865e..d449ffc 100644 --- a/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx +++ b/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx @@ -1,21 +1,21 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import { beforeEach, describe, expect, it, vi } from "vitest"; import { CareerChecklist } from "@/domains/new_dashboard/components/home/CareerChecklist"; -import { Header } from "@/domains/new_dashboard/components/layout/Header"; -import { MessageDetailModal } from "@/domains/new_dashboard/components/layout/MessageDetailModal"; import { JobDetailModal } from "@/domains/new_dashboard/components/jobs/JobDetailModal"; import { JobRow } from "@/domains/new_dashboard/components/jobs/JobRow"; +import { Header } from "@/domains/new_dashboard/components/layout/Header"; +import { MessageDetailModal } from "@/domains/new_dashboard/components/layout/MessageDetailModal"; import { MentoringTab } from "@/domains/new_dashboard/components/mentoring/MentoringTab"; import { ProfileForm } from "@/domains/new_dashboard/components/profile/ProfileForm"; import { Modal } from "@/domains/new_dashboard/components/shared/Modal"; import { - clearDashboardNotifications, - getDashboardNotificationFeed, - markDashboardNotificationsRead, + clearDashboardNotifications, + getDashboardNotificationFeed, + markDashboardNotificationsRead, } from "@/domains/new_dashboard/infrastructure/notificationsApi"; import type { Job, UserProfile } from "@/domains/new_dashboard/types"; import { DASHBOARD_NOTIFICATIONS_REFRESH_EVENT } from "@/domains/new_dashboard/utils/notificationEvents"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mockUseAuth = vi.fn(); const mockUseTheme = vi.fn(); @@ -73,6 +73,15 @@ const profileWithoutAvatar: UserProfile = { technologyExperiences: [{ name: "React", years: 2 }], }; +function getCurrentMonthLabel() { + const currentMonth = new Date().toISOString().slice(0, 7); + const [year, monthNumber] = currentMonth.split("-").map(Number); + return new Intl.DateTimeFormat("pt-BR", { + month: "long", + year: "numeric", + }).format(new Date(year, monthNumber - 1, 1)); +} + describe("new_dashboard branch coverage", () => { beforeEach(() => { localStorage.clear(); @@ -299,6 +308,7 @@ describe("new_dashboard branch coverage", () => { it("usa o checklist com título padrão, enter e remoção da lista", () => { render(); + const currentMonthLabel = getCurrentMonthLabel(); expect( screen.getByText(/crie uma lista mensal para começar/i), @@ -306,7 +316,9 @@ describe("new_dashboard branch coverage", () => { fireEvent.click(screen.getByRole("button", { name: /^lista$/i })); expect( - screen.getByRole("button", { name: /checklist de julho de 2026/i }), + screen.getByRole("button", { + name: new RegExp(`checklist de ${currentMonthLabel}`, "i"), + }), ).toBeInTheDocument(); fireEvent.change(screen.getByPlaceholderText(/novo item do checklist/i), { diff --git a/frontend/tests/unit/new_dashboard/home.profile.test.tsx b/frontend/tests/unit/new_dashboard/home.profile.test.tsx index 93df62b..b08707d 100644 --- a/frontend/tests/unit/new_dashboard/home.profile.test.tsx +++ b/frontend/tests/unit/new_dashboard/home.profile.test.tsx @@ -3,18 +3,27 @@ import { HomeTab } from "@/domains/new_dashboard/components/home/HomeTab"; import { PreferencesForm } from "@/domains/new_dashboard/components/profile/PreferencesForm"; import { ProfileForm } from "@/domains/new_dashboard/components/profile/ProfileForm"; import { - initialPreferences, - initialUser, + initialPreferences, + initialUser, } from "@/domains/new_dashboard/constants"; import type { - SearchPreferences, - UserProfile, + SearchPreferences, + UserProfile, } from "@/domains/new_dashboard/types"; import { fireEvent, render, screen } from "@testing-library/react"; import type { ReactElement } from "react"; import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +function getCurrentMonthLabel() { + const currentMonth = new Date().toISOString().slice(0, 7); + const [year, monthNumber] = currentMonth.split("-").map(Number); + return new Intl.DateTimeFormat("pt-BR", { + month: "long", + year: "numeric", + }).format(new Date(year, monthNumber - 1, 1)); +} + function renderWithProfileState( ui: (props: { userProfile: UserProfile; @@ -132,6 +141,7 @@ describe("new_dashboard home and profile components", () => { it("permite criar lista, adicionar item, marcar e excluir no CareerChecklist", () => { render(); + const currentMonthLabel = getCurrentMonthLabel(); fireEvent.change(screen.getByPlaceholderText(/nome da nova lista/i), { target: { value: "Metas" }, @@ -140,7 +150,7 @@ describe("new_dashboard home and profile components", () => { expect(screen.getByRole("button", { name: /metas/i })).toBeInTheDocument(); expect( - screen.getByText("julho de 2026", { selector: "span" }), + screen.getByText(currentMonthLabel, { selector: "span" }), ).toBeInTheDocument(); fireEvent.change(screen.getByPlaceholderText(/novo item do checklist/i), { diff --git a/package-lock.json b/package-lock.json index 02c8f26..61d1c8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,17 +54,23 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@react-email/render": "^2.1.0", "argon2": "^0.44.0", "axios": "^1.17.0", + "bullmq": "^5.81.2", "cheerio": "^1.2.0", "cors": "^2.8.6", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "express": "^5.2.1", + "ioredis": "^5.11.1", "iron-session": "^8.0.4", "pdfkit": "^0.18.0", "prom-client": "^15.1.3", + "react": "^19.2.8", + "react-dom": "^19.2.8", "redis": "^5.12.1", + "resend": "^6.18.0", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", "xlsx": "^0.18.5", @@ -73,6 +79,7 @@ "devDependencies": { "@types/node": "^25.9.1", "@types/pg": "^8.20.0", + "@types/react": "^19.2.17", "@types/supertest": "^7.2.0", "@vitest/coverage-v8": "^4.1.8", "drizzle-kit": "^0.31.10", @@ -1991,20 +1998,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", + "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -2012,9 +2019,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "license": "MIT", "optional": true, "dependencies": { @@ -2051,11 +2058,13 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -2220,11 +2229,13 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -2457,11 +2468,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2756,6 +2769,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -2965,6 +2984,84 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -4646,6 +4743,25 @@ "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", "license": "MIT" }, + "node_modules/@react-email/render": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.1.0.tgz", + "integrity": "sha512-F+zE3O6d6sW6Aj2UjvZAA17R7tJKM7kcq2mgV6k4HCT8jeLLFaVP2txMtH1lgqYFRMZ0Gxsd37q2PRyiXLXXxA==", + "license": "MIT", + "dependencies": { + "entities": "^4.5.0", + "html-to-text": "^9.0.5", + "html5parser": "^3.0.0", + "prettier": "^3.5.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, "node_modules/@redis/bloom": { "version": "5.12.1", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", @@ -4928,6 +5044,37 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", @@ -4979,6 +5126,19 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@simple-libs/child-process-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz", @@ -5033,6 +5193,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -7258,6 +7424,31 @@ "node": ">= 14" } }, + "node_modules/bullmq": { + "version": "5.81.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.2.tgz", + "integrity": "sha512-Hi9GaVCC6HE9bQP65j/FNv1aL1fcEukTF99ezS5pl1Ud+joCFpNWiPCV45mWdkEmpuacS0XJdMMFGKPIHCwoPg==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.11.1", + "msgpackr": "2.0.4", + "node-abort-controller": "3.1.1", + "semver": "7.8.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -8275,6 +8466,18 @@ "node": ">=0.8" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -8613,6 +8816,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -10102,11 +10314,13 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -10118,11 +10332,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -10134,11 +10350,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -10150,11 +10368,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -10166,11 +10386,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -10182,11 +10404,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10198,11 +10422,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10214,11 +10440,13 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10230,11 +10458,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10246,11 +10476,13 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10262,11 +10494,13 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10278,11 +10512,13 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10294,11 +10530,13 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10310,11 +10548,13 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10326,11 +10566,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -10342,11 +10584,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10358,11 +10602,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10374,11 +10620,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10390,11 +10638,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -10406,11 +10656,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -10422,11 +10674,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -10438,11 +10692,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -10454,11 +10710,13 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -11093,6 +11351,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", @@ -11927,6 +12191,47 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "license": "MIT" }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/html-to-text/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/html5parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html5parser/-/html5parser-3.0.0.tgz", + "integrity": "sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A==", + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -12185,6 +12490,37 @@ } } }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -12775,6 +13111,15 @@ "dev": true, "license": "MIT" }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -12836,6 +13181,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12856,6 +13202,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12876,6 +13223,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12896,6 +13244,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12916,6 +13265,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12936,6 +13286,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12956,6 +13307,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12976,6 +13328,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12996,6 +13349,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13016,6 +13370,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13036,6 +13391,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13375,6 +13731,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -13706,6 +14071,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz", + "integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -13772,6 +14168,12 @@ "node": ">=22.12.0" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", @@ -13827,6 +14229,21 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-gyp/node_modules/isexe": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", @@ -14438,6 +14855,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -14571,6 +15001,15 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/pg": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", @@ -14871,6 +15310,12 @@ "browserify-zlib": "^0.2.0" } }, + "node_modules/postal-mime": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz", + "integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -15112,6 +15557,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -15525,24 +15985,24 @@ } }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/react-icons": { @@ -15806,6 +16266,27 @@ "node": ">= 18.19.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -15843,6 +16324,27 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/resend": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.18.0.tgz", + "integrity": "sha512-EjxZ9AVzywJgOlUoIJe9ytBWVrfbUtJbjeoLnRSvpU1sv97Hh9DSwhw+k8kiujrG4Rg4bzTBsjlmwWWuoOxSug==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.5", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -16181,6 +16683,18 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -16606,6 +17120,22 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "license": "MIT" }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -18320,22 +18850,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "optional": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",