Skip to content

Latest commit

 

History

History
862 lines (650 loc) · 33.5 KB

File metadata and controls

862 lines (650 loc) · 33.5 KB

Arquitetura do Match Tech

Este documento descreve a arquitetura técnica do projeto, os padrões de design adotados, a organização de pastas e as convenções que devem ser seguidas ao contribuir.


Sumário

  1. Visão Geral
  2. Diagrama de Camadas
  3. Estrutura de Pastas Detalhada
  4. Camada de Domínio
  5. Camada de Infraestrutura
  6. Camada de Features
  7. Camada Shared
  8. Roteamento
  9. API Server
  10. Fluxo de Dados
  11. Injeção de Dependência
  12. Gerenciamento de Estado — Zustand
  13. Server State — TanStack Query
  14. Padrões e Convenções
  15. Testes
  16. Deploy e CI/CD
  17. Docker e Containers
  18. Scripts Utilitários
  19. Revisão de Código e PRs

1. Visão Geral

O Match Tech é uma SPA (Single Page Application) React com um backend Express mínimo usado exclusivamente para chamadas à API do Gemini (IA). O frontend se comunica diretamente com o Firebase para autenticação e banco de dados.

A arquitetura segue os princípios de Clean Architecture:

  • Regras de negócio ficam na camada de domínio, sem dependências externas
  • Detalhes de implementação (Firebase, Gemini) ficam nas camadas externas
  • Features são independentes entre si e dependem do domínio, nunca umas das outras
  • Inversão de dependência: componentes dependem de interfaces, não de implementações concretas

2. Diagrama de Camadas

┌─────────────────────────────────────────────────────────┐
│                      APRESENTAÇÃO                       │
│   features/   routes/   layouts/   shared/components/   │
└─────────────────────┬───────────────────────────────────┘
                      │ usa
┌─────────────────────▼───────────────────────────────────┐
│                    APLICAÇÃO                            │
│        shared/hooks/   shared/context/                  │
│        shared/services/   contexts/                     │
└─────────────────────┬───────────────────────────────────┘
                      │ depende de interfaces de
┌─────────────────────▼───────────────────────────────────┐
│                     DOMÍNIO                             │
│   domain/entities/   domain/ports/   domain/usecases/   │
└─────────────────────┬───────────────────────────────────┘
                      │ implementado por
┌─────────────────────▼───────────────────────────────────┐
│                  INFRAESTRUTURA                         │
│        infrastructure/firebase/                         │
│        server/ (Express + Gemini)                       │
└─────────────────────────────────────────────────────────┘

A regra fundamental: as setas de dependência apontam sempre para dentro — o domínio não conhece nenhuma camada externa.


3. Estrutura de Pastas Detalhada

src/
│
├── domain/                        ← Núcleo da aplicação (sem imports externos)
│   ├── entities/
│   │   ├── Member.ts              # Tipo Member e sub-interfaces (ISP)
│   │   ├── Squad.ts               # Tipo Squad e SquadMember
│   │   └── Shared.ts              # RoastPersona, Tag, SkillRadar, SquadStatus
│   ├── ports/
│   │   ├── IProfileRepository.ts  # Interface do repositório de perfis
│   │   ├── ISquadRepository.ts    # Interface do repositório de squads
│   │   ├── IAuthService.ts        # Interface do serviço de autenticação
│   │   └── IRoastService.ts       # Interface do serviço de roast/IA
│   └── usecases/
│       ├── compatibilityAlgorithm.ts  # calculateCompatibility, scoreSkillsForRole, etc.
│       └── __tests__/
│           └── compatibilityAlgorithm.test.ts
│
├── infrastructure/                ← Implementações concretas
│   └── firebase/
│       ├── profileRepository.ts   # FirebaseProfileRepository implements IProfileRepository
│       ├── squadRepository.ts     # FirebaseSquadRepository implements ISquadRepository
│       ├── schemas.ts             # Schemas Zod para validação em runtime
│       └── index.ts               # Barrel export
│
├── features/                      ← Módulos de produto (cada um é independente)
│   ├── discover/
│   │   ├── components/            # ProfilesGrid, RoastModal, DiscoverFilters...
│   │   ├── hooks/                 # useProfilesRealtime, useDiscoverFilters, useRoastProfile
│   │   ├── model/                 # discover.types.ts, discover.selectors.ts
│   │   ├── services/              # discover.repository.ts (updateProfile)
│   │   ├── constants/
│   │   └── pages/
│   │       └── DiscoverPage.tsx
│   ├── guilda/
│   │   ├── components/            # GuildMembersGrid, GuildRoastModal, GuildMemberCard...
│   │   ├── hooks/                 # useGuildMembersRealtime, useGuildRoast
│   │   ├── model/                 # guilda.types.ts, guilda.selectors.ts
│   │   ├── services/              # guilda.repository.ts (saveRoast)
│   │   ├── constants/
│   │   ├── utils/
│   │   └── pages/
│   │       └── GuildaPage.tsx
│   ├── onboarding/
│   │   ├── components/
│   │   │   └── AuthGate/          # LoginScreen, MagicLinkSentScreen, CompletingMagicLink...
│   │   ├── hooks/
│   │   │   └── useOnboardingForm.ts
│   │   ├── constants/
│   │   └── pages/
│   │       └── Onboarding.tsx
│   ├── profile/
│   │   ├── components/
│   │   │   └── ShareProfileButton.tsx   # Exportação PNG via canvas
│   │   └── pages/
│   │       └── PublicProfilePage.tsx    # Rota pública /p/:uid
│   ├── squad/
│   │   └── pages/
│   │       └── JoinSquadPage.tsx        # Rota de convite /join/:squadId
│   └── landing/
│       └── Landing.tsx
│
├── shared/                        ← Código reutilizável entre features
│   ├── components/
│   │   ├── ui/                    # Componentes puramente apresentacionais
│   │   │   ├── RoastModal.tsx     # Modal genérico de veredito (usado em discover e guilda)
│   │   │   ├── SkillRadar.tsx     # Radar chart memoizado
│   │   │   ├── ProfileCard.tsx    # Card de perfil
│   │   │   ├── Avatar.tsx
│   │   │   ├── Button.tsx
│   │   │   ├── Card.tsx
│   │   │   ├── ErrorBoundary.tsx
│   │   │   └── ...
│   │   └── states/                # Estados de UI (loading, empty, error)
│   ├── hooks/
│   │   ├── useFirestoreSubscription.ts  # Hook genérico para onSnapshot em tempo real
│   │   └── index.ts
│   ├── context/
│   │   └── RepositoryContext.tsx  # RepositoryProvider + useRepositories()
│   ├── lib/
│   │   ├── firebase/
│   │   │   ├── firebase.client.ts # Inicialização do Firebase SDK
│   │   │   └── firebase.config.ts
│   │   ├── logger/                # Logger estruturado
│   │   ├── utils/                 # cn(), entity helpers
│   │   └── AppError.ts            # Classe AppError + códigos + mensagens PT-BR
│   └── services/
│       └── roast.service.ts       # Cliente HTTP para POST /api/roast
│
├── routes/                        ← Configuração de roteamento
│   ├── routes.tsx                 # createBrowserRouter com todas as rotas
│   ├── ProtectedLayout.tsx        # Re-exporta RootLayout como Component lazy
│   └── ErrorPage.tsx              # Página de erro de rota (trata AppError)
│
├── layouts/
│   └── RootLayout.tsx             # Shell da aplicação: nav + Outlet + modal de logout
│
├── contexts/
│   └── AuthContext.tsx            # Firebase Auth (Google OAuth + Magic Link)
│
├── server/                        ← API Express (backend serverless no Vercel)
│   ├── app.ts                     # Instância Express
│   ├── routes/
│   │   └── index.ts               # Registro das rotas
│   ├── features/
│   │   ├── roast/                 # POST /api/roast — análise Gemini individual
│   │   │   ├── roast.controller.ts
│   │   │   ├── roast.service.ts
│   │   │   ├── roast.repository.ts
│   │   │   ├── roast.routes.ts    # Rate limit: 5 req/min
│   │   │   ├── roast.prompts.ts
│   │   │   └── roast.types.ts
│   │   └── oraculo/               # POST /api/oraculo/match — match por IA
│   │       ├── match.controller.ts
│   │       ├── match.service.ts
│   │       ├── match.routes.ts    # Rate limit: 10 req/min
│   │       ├── match.prompts.ts
│   │       └── match.types.ts
│   └── shared/
│       ├── lib/
│       │   ├── gemini.server.ts   # Cliente Gemini (gemini-2.5-flash)
│       │   └── firebase-admin.server.ts
│       └── utils/
│           └── async-handler.ts
│
├── App.tsx                        # QueryClientProvider + AuthProvider + RouterProvider
└── main.tsx                       # createRoot + global error listeners

4. Camada de Domínio

A camada de domínio contém as regras de negócio puras. Nenhum arquivo aqui pode importar Firebase, React, ou qualquer lib externa.

Entidades (domain/entities/)

As entidades seguem o Interface Segregation Principle (ISP): em vez de um tipo Member gigante, temos interfaces menores que são compostas:

// Member é a composição de interfaces focadas
type Member = MemberIdentity      // uid, displayName, photoURL, bio
            & MemberRoles         // role, secondaryRoles[]
            & MemberSkills        // skills: SkillRadar
            & MemberTags          // tags: Tag[]
            & MemberSquadStatus   // squadId?, squadStatus
            & { createdAt, updatedAt, visibility }

// PublicMember expõe apenas o que é seguro tornar público
type PublicMember = MemberIdentity & MemberRoles & { visibility: "public"; tags?: Tag[] }

Isso permite que componentes recebam apenas as interfaces que precisam (ProfileCardData = MemberIdentity & MemberRoles), sem carregar campos desnecessários.

Ports (domain/ports/)

Ports são interfaces TypeScript que definem contratos entre o domínio e a infraestrutura:

interface IProfileRepository {
  getProfile(uid: string): Promise<Member>
  getPublicProfile(uid: string): Promise<PublicMember>
  updateProfile(uid: string, data: Partial<Member>): Promise<void>
  listPublicProfiles(filters?: ProfileFilters): Promise<Member[]>
  deleteProfile(uid: string): Promise<void>
}

O domínio só conhece essa interface. A implementação concreta (FirebaseProfileRepository) fica na infraestrutura e pode ser trocada (ex: para um mock em testes) sem mudar nada no domínio ou nas features.

Usecases (domain/usecases/)

Funções puras com a lógica de negócio central:

Função Descrição
calculateCompatibility(m1, m2) Score 0–90: 40% overlap de skills + 40% compatibilidade de tags + 10% bônus de roles diferentes
scoreSkillsForRole(skills, role) Pontua skills de 0–100 com pesos específicos por role
filterMembers(members, role?, status?) Filtra lista de membros por role e/ou squadStatus
sortByCompatibility(members, target) Ordena lista pelo score de compatibilidade com o membro alvo
getTopCompatibleMembers(members, target, limit) Retorna os N membros mais compatíveis

Algoritmo de compatibilidade em detalhe:

calculateCompatibility(m1, m2):
  skillOverlap  = 100 - (Σ|skill_i_m1 - skill_i_m2| / maxDiff) * 100
  tagCompat     = 100 - penalidades (love↔veto = -20, veto↔veto = -5) → mínimo 0
  roleBonus     = m1.role ≠ m2.role ? 10 : 0
  score         = Math.round(skillOverlap * 0.4 + tagCompat * 0.4 + roleBonus)
  range         = [0, 90]

5. Camada de Infraestrutura

Implementa as interfaces do domínio usando Firebase e Zod.

Repositórios (infrastructure/firebase/)

Cada repositório:

  1. Implementa a interface correspondente do domínio (implements IProfileRepository)
  2. Usa MemberSchema.parse(snap.data()) para validar dados do Firestore com Zod
  3. Lança AppError com códigos semânticos em caso de erro (PROFILE_NOT_FOUND, FIRESTORE_UNAVAILABLE, etc.)
  4. Exporta uma instância singleton (export const profileRepository = new FirebaseProfileRepository())

Schemas Zod (infrastructure/firebase/schemas.ts)

Schemas Zod validam todos os dados vindos do Firestore em runtime, protegendo contra drift de schema:

const MemberSchema = z.object({
  uid: z.string(),
  displayName: z.string(),
  skills: SkillRadarSchema,
  tags: z.array(TagSchema).min(10),
  squadStatus: z.enum(["open", "looking", "closed"]).default("open"),
  // ...
})

Se um documento do Firestore não corresponder ao schema, um ZodError é lançado e convertido em AppError("FIRESTORE_UNAVAILABLE").


6. Camada de Features

Cada feature é um módulo independente organizado por domínio de produto. A estrutura interna padrão de uma feature é:

features/nome-da-feature/
├── components/     # Componentes React específicos desta feature
├── hooks/          # Custom hooks (estado, side effects, acesso a dados)
├── model/          # Types locais e funções de seleção/transformação
├── services/       # Chamadas diretas ao Firestore (operações de escrita)
├── constants/      # Constantes estáticas (listas de roles, categorias de tags)
└── pages/          # Componentes de página (montados pelo roteador)

Regras importantes

  • Features não importam de outras features. Se um componente precisa ser compartilhado, ele vai para shared/.
  • Dados em tempo real usam useFirestoreSubscription<T> da camada shared.
  • Operações de escrita ficam nos services/ da feature (ex: updateProfile, saveRoast).
  • Estado de UI fica nos hooks/ (ex: useDiscoverFilters, useGuildRoast).

7. Camada Shared

shared/components/ui/

Componentes puramente apresentacionais — sem fetching de dados, sem efeitos colaterais de negócio:

  • Recebem dados via props
  • Emitem eventos via callbacks
  • Aceitam className para override de estilo
  • São testáveis de forma isolada

Exemplo: RoastModal — modal genérico de veredito usado em discover e guilda. A lógica específica de cada feature fica nos hooks, o modal apenas renderiza o que recebe.

shared/hooks/useFirestoreSubscription<T>

Hook genérico que elimina a duplicação de subscriptions em tempo real:

const { data, loading, error } = useFirestoreSubscription<Profile>({
  collectionName: "profiles",
  constraints: [where("visibility", "==", "public")], // opcional
})

Gerencia automaticamente: subscription/cleanup, loading state, error state, unmount seguro.

shared/lib/AppError.ts

Classe de erro centralizada com:

  • Códigos semânticos (PROFILE_NOT_FOUND, UNAUTHORIZED, ANALYSIS_FAILED...)
  • Mapeamento para HTTP status codes
  • Mensagens em português via getUserErrorMessage(code)
  • Função isAppError(error) para type narrowing

shared/context/RepositoryContext.tsx

Implementa o Dependency Inversion Principle (DIP):

// Fornece implementações Firebase por padrão
<RepositoryProvider>
  {children}
</RepositoryProvider>

// Em testes, injeta mocks
<RepositoryProvider profileRepo={mockProfileRepo} squadRepo={mockSquadRepo}>
  {children}
</RepositoryProvider>

// Nos componentes:
const { profileRepo, squadRepo } = useRepositories()

8. Roteamento

O projeto usa React Router v7 em framework mode (createBrowserRouter + RouterProvider), não library mode.

Hierarquia de rotas

/                    ← Shell (RootLayout) — nav + Outlet
├── /                  Landing (público)
├── /onboarding        Cadastro de perfil (público — callback do magic link)
├── /discover          Lista de perfis (requireAuth)
├── /guilda            Gestão da guilda (requireAuth)
├── /p/:uid            Perfil público (sem auth — loader: getPublicProfile)
└── /join/:squadId     Convite de squad (sem auth — loader: getSquad)

Auth guard (requireAuth)

Loader assíncrono que roda antes de qualquer rota protegida:

async function requireAuth() {
  await auth.authStateReady() // aguarda Firebase resolver persistência
  if (!auth.currentUser) throw redirect("/")
  return null
}

/onboarding é intencionalmente deixado sem guard: é a URL de callback do magic link, onde o Firebase conclui a autenticação após o carregamento da página.

Lazy loading

Todas as páginas são carregadas de forma lazy. O build gera um chunk separado por página:

ProtectedLayout-*.js   ~8 kB
Landing-*.js           ~9 kB
DiscoverPage-*.js      ~19 kB
GuildaPage-*.js        ~18 kB
Onboarding-*.js        ~40 kB

Tratamento de erros de rota

O ErrorPage captura erros de loaders (ex: PROFILE_NOT_FOUND, SQUAD_NOT_FOUND) e exibe mensagens amigáveis em português via getUserErrorMessage().


9. API Server

O backend é uma instância Express mínima, usada exclusivamente para ocultar as chaves da API do Gemini do bundle do cliente.

Endpoints

Método Rota Rate Limit Descrição
GET /api/health Status check
POST /api/roast 5 req/min Gera análise individual (brutal ou suave) via Gemini
POST /api/oraculo/match 10 req/min Gera sugestões de match via Gemini

Deploy no Vercel

{
  "rewrites": [{ "source": "/api/(.*)", "destination": "/api/index.ts" }]
}

Toda requisição para /api/* é encaminhada para a serverless function em /api/index.ts. O restante é servido como SPA estática via CDN do Vercel.


10. Fluxo de Dados

Leitura em tempo real (Discover / Guilda)

Firestore
  └─ onSnapshot("profiles")
       └─ useFirestoreSubscription<Profile>
            └─ useProfilesRealtime(currentUserId)
                 ├─ sortProfiles(data, currentUserId) via useMemo
                 └─ useDiscoverFilters(profiles)
                      └─ ProfilesGrid (virtualizado) → ProfileCard

Geração de roast

Usuário clica em "Gerar Sina"
  └─ useRoastProfile.executeRoast(profile, persona)
       ├─ verifica se já existe roast em cache (profile.roastBrutal / roastMild)
       ├─ se não existe → POST /api/roast (shared/services/roast.service)
       │    └─ Express → Gemini API → resposta
       ├─ updateProfile(profile.id, { roastBrutal: text }) → Firestore
       └─ setSelectedProfile({ ...profile, roastBrutal: text })
            └─ RoastModal re-renderiza com o novo texto

Perfil público

Usuário acessa /p/abc123
  └─ Route loader: profileRepository.getPublicProfile("abc123")
       ├─ getDoc(db, "members", "abc123")
       ├─ verifica visibility === "public" (AppError UNAUTHORIZED se não for)
       └─ PublicMemberSchema.parse(data)
            └─ PublicProfilePage recebe dados via useLoaderData()

Convite de squad

Usuário acessa /join/squad456
  └─ Route loader: squadRepository.getSquad("squad456")
       └─ JoinSquadPage recebe Squad via useLoaderData()
            ├─ Não autenticado → salva "pendingJoin" no sessionStorage → /onboarding
            └─ Autenticado → squadRepo.addMemberToSquad(id, uid) → /guilda

11. Injeção de Dependência

O projeto usa Context API como container de DI, sem libs externas:

App.tsx
└─ QueryClientProvider      ← TanStack Query (server state cache)
   └─ AuthProvider          ← Firebase Auth state
      └─ RepositoryProvider ← Repositórios injetados
         └─ RouterProvider  ← Páginas têm acesso a tudo acima

Para trocar implementações em testes de integração:

render(
  <RepositoryProvider
    profileRepo={new MockProfileRepository()}
    squadRepo={new MockSquadRepository()}
  >
    <ComponenteATestar />
  </RepositoryProvider>
)

12. Gerenciamento de Estado — Zustand

O projeto usa Zustand para estado de UI que precisa:

  • Sobreviver entre re-renders sem re-montar
  • Ser acessado por múltiplos componentes dentro da mesma feature sem prop drilling
  • Ser persistido durante navegação (ex: filtros da Discover)

Quando usar Zustand

Use Zustand quando... Use useState quando...
O estado é compartilhado entre 2+ componentes O estado é local a um único componente
Você quer que persista durante a navegação O estado pode ser resetado ao desmontar
O estado tem múltiplos campos relacionados O estado é um único valor simples

Stores existentes

features/discover/store/discoverFilters.ts

Substitui 4 useState em useDiscoverFilters. Os filtros persistem quando o usuário navega para outra página e volta.

const useDiscoverFiltersStore = create<DiscoverFiltersState>((set) => ({
  searchQuery: "",
  selectedRole: "ALL",
  selectedStatus: "ALL",
  selectedTag: "",
  setSearchQuery: (searchQuery) => set({ searchQuery }),
  // ...
}));

features/guilda/store/guildRoast.ts

Centraliza o estado complexo do roast da guilda: selectedMember, roastActiveMember, roastStep, roastLogs, activePersonaView. Os hooks que precisam desses valores (ex: GuildMemberCard e GuildRoastModal) leem do store sem prop drilling.

Convenção de criação de stores

Stores ficam em features/<nome>/store/<nome>.ts. Cada store exporta um único hook (useXxxStore) criado com create<T>(). Actions (setters) ficam dentro do próprio store — não use set em componentes:

// ✅ correto — action encapsulada no store
setSearchQuery: (searchQuery) => set({ searchQuery }),

// ❌ evitar — chamar set diretamente no componente
useDiscoverFiltersStore.setState({ searchQuery: "..." })

13. Server State — TanStack Query

TanStack Query (@tanstack/react-query) é usado para gerenciar chamadas à API — específicamente as requisições à IA (roast e match). Já está instalado e QueryClientProvider está configurado em App.tsx.

Quando usar TanStack Query

Use useMutation quando... Use fetch manual quando...
Chama a API e precisa de loading/error/success Nunca — prefira sempre useMutation
Quer retry automático
Quer callbacks onSuccess, onError, onSettled

Dados em tempo real do Firestore usam useFirestoreSubscription<T> (push-based, onSnapshot), não useQuery. TanStack Query é para chamadas HTTP pontuais.

Onde está sendo usado

features/discover/hooks/useRoastProfile.ts — substitui isGenerating state + try/catch manual:

const roastMutation = useMutation({
  mutationFn: ({ profile, persona }) =>
    requestRoast({ memberId: profile.id, memberData: profile, persona }),
  onSuccess: async (data, { profile, persona }) => { /* salva e atualiza */ },
  onError: (error) => { showToast("Sem conexão com o servidor de IA...") },
});

// isGenerating era um useState manual — agora é derivado:
isGenerating: roastMutation.isPending,

features/guilda/hooks/useGuildRoast.ts — mesma abordagem com callbacks onMutate (start animation), onSuccess (save), onSettled (cleanup):

const roastMutation = useMutation({
  mutationFn: ({ member, persona }) => requestRoast({ ... }),
  onMutate: ({ persona }) => { startLogs(persona) },
  onSuccess: async (data, { member, persona }) => { await saveRoast(...) },
  onSettled: () => { clearLogsInterval(); store.setRoastStep(null) },
});

Configuração global

// App.tsx
const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 1000 * 60 * 5, retry: 2 },
  },
});

ReactQueryDevtools é renderizado apenas em import.meta.env.DEV.


14. Padrões e Convenções

Importações

O alias @/ aponta para src/. Sempre use o alias, nunca caminhos relativos que atravessam mais de uma pasta:

// ✅ correto
import { AppError } from "@/shared/lib/AppError"
import type { Member } from "@/domain/entities/Member"

// ❌ evitar
import { AppError } from "../../../shared/lib/AppError"

Ordem de importações (enforçada pelo ESLint):

  1. Pacotes externos (react, firebase, motion/react...)
  2. Imports internos com @/ (domínio → infraestrutura → shared → features)
  3. Imports relativos do próprio módulo

Componentes

  • Componentes de página têm export default e ficam em pages/
  • Componentes de UI têm export function nomeado e ficam em components/
  • Lazy routes exportam default para que o .then(m => ({ Component: m.default })) funcione
  • Componentes puramente apresentacionais não fazem fetch de dados

Hooks

  • Hooks que gerenciam dados externos usam useFirestoreSubscription<T> da camada shared
  • Sorting e transformações de dados ficam em useMemo dentro do hook, não no sortFn do subscription (evita re-triggers infinitos)

Erros

Sempre lance AppError com código semântico. Nunca throw new Error("alguma coisa") em código de produção:

// ✅
throw new AppError("PROFILE_NOT_FOUND", uid)

// ❌
throw new Error("perfil não encontrado")

Estilo

O projeto usa Tailwind CSS v4 com tema neo-brutalista. As classes de tema principais:

Classe Cor / Uso
bg-neo-black / text-neo-black Preto #0a0a0a — texto e bordas principais
bg-neo-lime Verde limão #B8FF29 — destaque primário
bg-neo-pink Rosa #FF2E93 — alertas e ações destrutivas
bg-neo-cyan Ciano #00E5FF — ações secundárias
bg-neo-bg Fundo #f5f5f0 — fundo padrão da app
shadow-[4px_4px_0_0_#000] Sombra neo-brutalista deslocada
border-[3px] border-neo-black Borda grossa padrão
font-heading Fonte display (títulos em caixa alta)

15. Testes

Configuração

Vitest está configurado no vite.config.ts com environment: "node" e alias @/ para que os testes importem tipos de domínio normalmente.

O que testar

Camada O que testar Ferramenta
domain/usecases/ Funções puras (compatibilidade, filtros, ranking) Vitest
infrastructure/firebase/ Repositórios contra Firestore Emulator Vitest + Firebase Emulator
features/*/hooks/ Lógica de estado com mocks de repositório Vitest + Testing Library
shared/components/ui/ Renderização e interação Vitest + Testing Library

Rodando os testes

npm test                               # modo watch
npx vitest run                         # execução única
npx vitest run --reporter=verbose      # com detalhes

Estrutura de um arquivo de teste

// src/domain/usecases/__tests__/minhaFuncao.test.ts
import { describe, it, expect } from "vitest"
import { minhaFuncao } from "../minhaFuncao"

// Helper para criar fixtures
function criarMembro(overrides: Partial<Member> = {}): Member {
  return { /* valores padrão */ ...overrides }
}

describe("minhaFuncao", () => {
  it("descrição do comportamento esperado", () => {
    expect(minhaFuncao(criarMembro())).toBe(valorEsperado)
  })
})

16. Deploy e CI/CD

Pipeline de CI (GitHub Actions)

Executa em todo PR e push para main:

typecheck → lint (zero avisos) → build

O build falha se qualquer etapa falhar, bloqueando o merge.

Deploy (Vercel)

O deploy é automático ao fazer merge em main:

  1. Vercel detecta o push
  2. Roda npm run build → gera dist/
  3. Assets estáticos servidos via CDN global
  4. Funções serverless em /api/index.ts servem as rotas /api/*

Variáveis de ambiente no Vercel

Configure no painel do projeto em Vercel > Settings > Environment Variables:

Variável Obrigatória Descrição
GEMINI_API_KEY ✅ Sim Chave da API do Google Gemini
APP_URL ✅ Sim URL do app em produção (ex: https://matchtech-sooty.vercel.app)

As chaves do Firebase ficam em firebase-applet-config.json (commitado, sem dados sensíveis) e são lidas pelo cliente diretamente.


17. Docker e Containers

O projeto inclui suporte a containers para facilitar o setup local sem depender de instalações globais e para padronizar o ambiente entre desenvolvedores.

docker-compose.yaml

Orquestra dois serviços:

Serviço Dockerfile Porta Variáveis de Ambiente
frontend Dockerfile.frontend 3000 VITE_API_BASE_URL=http://backend:3001
backend Dockerfile.backend 3001 PORT=3001

O serviço frontend declara depends_on: backend, garantindo que o Express suba antes do Vite tentar se comunicar com a API. A comunicação entre os containers ocorre pela rede interna do Compose (DNS por nome de serviço), de modo que o bundle do cliente nunca expõe o endereço real do backend.

docker compose up --build   # sobe tudo
docker compose down         # encerra e remove os containers

Dockerfile.backend

Imagem simples baseada em node:22-bookworm. Instala todas as dependências (incluindo devDependencies, necessárias para o tsx) e executa o servidor Express diretamente com npx tsx src/server/server.ts — sem etapa de transpilação prévia. Isso mantém o startup rápido e o ciclo de debug simples em ambientes de staging.

node:22-bookworm
  └── npm ci --include=dev
  └── COPY src/ + tsconfig.json
  └── EXPOSE 3001
  └── CMD npx tsx src/server/server.ts

Dockerfile.frontend

Multi-stage build para minimizar o tamanho da imagem final:

  • Stage builder — instala deps e roda npm run build para gerar dist/
  • Stage runner — parte de uma imagem limpa, copia apenas o dist/ gerado e instala deps de dev (necessárias para vite preview), servindo o build via vite preview --host 0.0.0.0 --port 3000
builder: node:22-bookworm → npm ci → npm run build → dist/
runner:  node:22-bookworm → npm ci --include=dev → COPY dist/ → vite preview

Por que vite preview e não Nginx? O projeto não requer um servidor estático dedicado — vite preview é suficiente para staging/demo. Para produção real, prefira Nginx ou o deploy direto na Vercel.


18. Scripts Utilitários

Scripts avulsos que operam fora do ciclo de build/teste normal ficam em scripts/.

scripts/migrate-members-to-profiles.ts

Migração one-time da coleção Firestore membersprofiles. O projeto originalmente usava members; profiles é o destino definitivo com schema expandido.

Comportamento:

  • Por padrão roda em dry run — apenas exibe o que seria migrado, sem escrever nada.
  • Documentos já existentes em profiles não são sobrescritos (safe to re-run).
  • A coleção members não é modificada.
  • Erros por documento são reportados individualmente sem interromper o batch.

Uso:

# Dry run (padrão):
npx tsx scripts/migrate-members-to-profiles.ts

# Executar de fato:
npx tsx scripts/migrate-members-to-profiles.ts --execute

# Via npm script:
npm run migrate:members-to-profiles -- --execute

Pré-requisito: a variável FIREBASE_SERVICE_ACCOUNT deve conter o JSON da service account do Firebase Admin, ou GOOGLE_APPLICATION_CREDENTIALS deve apontar para o arquivo JSON equivalente.


19. Revisão de Código e PRs

Para manter a codebase organizada e estável à medida que mais desenvolvedores contribuem, adotamos um processo estruturado de revisão de Pull Requests.

O fluxo de trabalho, os critérios de aceitação para merges e os checklists de revisão estão documentados em detalhes no Guia de Revisão de PRs.