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.
- Visão Geral
- Diagrama de Camadas
- Estrutura de Pastas Detalhada
- Camada de Domínio
- Camada de Infraestrutura
- Camada de Features
- Camada Shared
- Roteamento
- API Server
- Fluxo de Dados
- Injeção de Dependência
- Gerenciamento de Estado — Zustand
- Server State — TanStack Query
- Padrões e Convenções
- Testes
- Deploy e CI/CD
- Docker e Containers
- Scripts Utilitários
- Revisão de Código e PRs
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
┌─────────────────────────────────────────────────────────┐
│ 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.
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
A camada de domínio contém as regras de negócio puras. Nenhum arquivo aqui pode importar Firebase, React, ou qualquer lib externa.
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 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.
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]
Implementa as interfaces do domínio usando Firebase e Zod.
Cada repositório:
- Implementa a interface correspondente do domínio (
implements IProfileRepository) - Usa
MemberSchema.parse(snap.data())para validar dados do Firestore com Zod - Lança
AppErrorcom códigos semânticos em caso de erro (PROFILE_NOT_FOUND,FIRESTORE_UNAVAILABLE, etc.) - Exporta uma instância singleton (
export const profileRepository = new FirebaseProfileRepository())
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").
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)
- 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).
Componentes puramente apresentacionais — sem fetching de dados, sem efeitos colaterais de negócio:
- Recebem dados via props
- Emitem eventos via callbacks
- Aceitam
classNamepara 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.
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.
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
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()O projeto usa React Router v7 em framework mode (createBrowserRouter + RouterProvider), não library mode.
/ ← 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)
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.
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
O ErrorPage captura erros de loaders (ex: PROFILE_NOT_FOUND, SQUAD_NOT_FOUND) e exibe mensagens amigáveis em português via getUserErrorMessage().
O backend é uma instância Express mínima, usada exclusivamente para ocultar as chaves da API do Gemini do bundle do cliente.
| 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 |
{
"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.
Firestore
└─ onSnapshot("profiles")
└─ useFirestoreSubscription<Profile>
└─ useProfilesRealtime(currentUserId)
├─ sortProfiles(data, currentUserId) via useMemo
└─ useDiscoverFilters(profiles)
└─ ProfilesGrid (virtualizado) → ProfileCard
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
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()
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
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>
)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)
| 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 |
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.
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: "..." })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.
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.
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) },
});// App.tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 1000 * 60 * 5, retry: 2 },
},
});ReactQueryDevtools é renderizado apenas em import.meta.env.DEV.
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):
- Pacotes externos (
react,firebase,motion/react...) - Imports internos com
@/(domínio → infraestrutura → shared → features) - Imports relativos do próprio módulo
- Componentes de página têm
export defaulte ficam empages/ - Componentes de UI têm
export functionnomeado e ficam emcomponents/ - Lazy routes exportam
defaultpara que o.then(m => ({ Component: m.default }))funcione - Componentes puramente apresentacionais não fazem fetch de dados
- Hooks que gerenciam dados externos usam
useFirestoreSubscription<T>da camada shared - Sorting e transformações de dados ficam em
useMemodentro do hook, não nosortFndo subscription (evita re-triggers infinitos)
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")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) |
Vitest está configurado no vite.config.ts com environment: "node" e alias @/ para que os testes importem tipos de domínio normalmente.
| 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 |
npm test # modo watch
npx vitest run # execução única
npx vitest run --reporter=verbose # com detalhes// 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)
})
})Executa em todo PR e push para main:
typecheck → lint (zero avisos) → buildO build falha se qualquer etapa falhar, bloqueando o merge.
O deploy é automático ao fazer merge em main:
- Vercel detecta o push
- Roda
npm run build→ geradist/ - Assets estáticos servidos via CDN global
- Funções serverless em
/api/index.tsservem as rotas/api/*
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.
O projeto inclui suporte a containers para facilitar o setup local sem depender de instalações globais e para padronizar o ambiente entre desenvolvedores.
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 containersImagem 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
Multi-stage build para minimizar o tamanho da imagem final:
- Stage
builder— instala deps e rodanpm run buildpara gerardist/ - Stage
runner— parte de uma imagem limpa, copia apenas odist/gerado e instala deps de dev (necessárias paravite preview), servindo o build viavite 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 previewe 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.
Scripts avulsos que operam fora do ciclo de build/teste normal ficam em scripts/.
Migração one-time da coleção Firestore members → profiles. 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
profilesnão são sobrescritos (safe to re-run). - A coleção
membersnã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 -- --executePré-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.
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.