Pular para o conteúdo principal

CORE vs BASE — Separação de Responsabilidades

O Princípio

O front-web-base é burro por design.

O template React apenas consome dados já normalizados e renderiza UI. Toda inteligência de domínio — regras de negócio, normalização de campos da API, transforms, constantes, validadores — vive nos pacotes @cactus-agents/* do front-cactus-core.

Isso garante que:

  • Qualquer novo front-end (novo fork, nova plataforma) não precisa "redescobrir" as regras
  • Agentes de IA que trabalham no projeto têm uma fonte de verdade clara
  • Bugs de normalização são corrigidos uma vez no SDK, refletem em todos os forks

Onde o código deve viver

Se é...Onde viveExemplo concreto
Shape real da API (snake_case)@cactus-agents/<pkg>/src/raw-types.tsRawLoginHistoryItem.date
Tipo público normalizado (camelCase)@cactus-agents/<pkg>/src/types.tsLoginHistoryItem.createdAt
Transform raw → público@cactus-agents/<pkg>/src/transform.tstransformLoginHistoryResponse()
Constante de domínio@cactus-agents/<pkg>/src/TIMEOUT_DAYS_OPTIONS, SELF_EXCLUSION_MONTHS_OPTIONS
Helper de cálculo@cactus-agents/<pkg>/src/isoDurationToHours(), parseLimitPeriod()
Validador de formato@cactus-agents/country-config/src/validators/validateClabe(), validateCpf()
Lógica condicional por país@cactus-agents/country-config/src/countries/bankAccountSection: "pix"
Feature flag da API@cactus-agents/brand/src/transform/features.tsaccountSetLimits, accountTimeoutLimits
Proxy HTTP server-sidefront-web-base/app/routes/api.*.tsapi.user.login-history.ts
Renderização de UIfront-web-base/app/components/**/*.tsxDepositLimitSection.tsx
Lógica de rota/navegaçãofront-web-base/app/routes/user/irpf.tsx com guard de país

Padrão Raw → Transform → Público

Todos os pacotes SDK seguem este padrão (inspirado no @cactus-agents/payments):

API (snake_case)

RawXxx { campo_api: string } ← nunca exposto ao front-end
↓ transformXxx()
Xxx { campoNormalizado: string } ← tipo público, camelCase, sempre válido

Exemplo: Login History

CORE — @cactus-agents/user/src/login-history.ts:

// Raw: o que a API realmente retorna
interface RawLoginHistoryItem {
user_id: number;
city: string | null;
state: string | null;
ip: string | null;
date: string; // "2026-03-17 18:14:42" — sem T, sem timezone
}

// Público: o que o front-end consome
interface LoginHistoryItem {
userId: number;
ip: string | null;
location: string | null; // "São Paulo, SP" — composto automaticamente
createdAt: string; // "2026-03-17T18:14:42" — ISO 8601 normalizado
}

function transformLoginHistoryItem(raw: RawLoginHistoryItem): LoginHistoryItem {
const locationParts = [raw.city, raw.state].filter(Boolean);
return {
userId: raw.user_id,
ip: raw.ip,
location: locationParts.join(", ") || null,
createdAt: raw.date.replace(" ", "T"), // corrige formato sem T
};
}

BASE — app/routes/user/login-history.tsx:

// Importa tipos do SDK — zero lógica de normalização
import type { LoginHistoryItem, LoginHistoryResponse } from "@cactus-agents/user";

// formatDate é trivial: createdAt já é ISO 8601
function formatDate(isoStr: string): string {
return new Date(isoStr).toLocaleString(intlLocale, { ... });
}

Padrão de proxy server-side

As rotas api/*.ts no template são proxies simples:

// app/routes/api.user.login-history.ts
import { transformLoginHistoryResponse, type RawLoginHistoryResponse } from "@cactus-agents/user";

export async function loader({ request, context }: Route.LoaderArgs) {
const token = getTokenFromRequest(request);
const client = createClient(cf, { request, getAccessToken: () => token });

// Chama a API diretamente (sem usar o UserService, pois é server-side proxy)
const { data: raw } = await client.get<RawLoginHistoryResponse>(
`/bff/users/login-history?page=${page}`,
);

// Transform acontece no proxy — o front-end recebe dados normalizados
return Response.json({ ok: true, data: transformLoginHistoryResponse(raw) });
}

Regra: Proxies só autenticam e delegam. Se precisar de uma função auxiliar, ela deve estar no CORE.


Constantes e helpers de domínio no CORE

@cactus-agents/user/src/responsible-gaming.ts

Fonte de verdade para todas as regras de responsible gaming:

export const TIMEOUT_DAYS_OPTIONS = [1, 3, 7, 14, 30, 45] as const;
export const SELF_EXCLUSION_MONTHS_OPTIONS = [3, 6, 12, 24, 36, 60, -1] as const;

export function parseLimitPeriod(value): LimitPeriodCode
export function isoDurationToHours(raw): number | null
export function hoursToIsoDuration(hours): string
export function isPermanentExclusion(months): boolean

No BASE — apenas importa:

import { TIMEOUT_DAYS_OPTIONS, isoDurationToHours } from "@cactus-agents/user";

// Zero lógica local
const currentHours = isoDurationToHours(userInfo.user_limit_time);

Validadores no @cactus-agents/country-config

import { validateClabe, stripClabe, CLABE_LENGTH } from "@cactus-agents/country-config";

// No componente ClabeSection.tsx — sem regex local
if (!validateClabe(stripClabe(clabeValue))) {
setError(t("user:account.clabe_invalid"));
}

Todos os validadores de documento e campo bancário ficam em packages/country-config/src/validators/:

ArquivoExports
cpf.tsvalidateCpf, formatCpf, stripCpf
rut.tsvalidateRut, formatRut, stripRut
curp.tsvalidateCurp, formatCurp, stripCurp
clabe.tsvalidateClabe, stripClabe, CLABE_LENGTH
dni.tsvalidateDni, formatDni, stripDni

Configuração por país no @cactus-agents/country-config

Decisões condicionais por país ficam no SDK, não em if (country === "CHL") no BASE:

// braConfig: bankAccountSection: "pix", legal.hasIncomeTaxReport: true
// chlConfig: bankAccountSection: "chl-banks", legal.hasIncomeTaxReport: false
// mexConfig: bankAccountSection: "clabe", legal.hasIncomeTaxReport: false

No BASE:

const { payments, legal } = useCountry();

// Nunca: if (country === "BRA") <PixSection />
// Sempre:
{payments.bankAccountSection === "pix" && <PixSection />}
{payments.bankAccountSection === "clabe" && <ClabeSection />}
{payments.bankAccountSection === "chl-banks" && <ChlBanksSection />}

// IRPF: guard automático pelo SDK
if (!legal.hasIncomeTaxReport) return <Navigate to="/user/account" />;

Feature flags da API no @cactus-agents/brand

Campos do endpoint /bff/features são transformados em BrandFeatures pelo SDK:

// Disponível via useBrand()
interface BrandFeatures {
accountSetLimits: boolean; // account_set_limits_active
accountTimeoutLimits: boolean; // account_timeout_limits_active
socialAuth: { facebook, google, steam, twitch: boolean };
userMigration: boolean;
// ...
}

No BASE:

const { features } = useBrand();

{features.accountSetLimits && <DepositLimitSection />}
{features.accountTimeoutLimits && <TimeoutSection />}
{features.socialAuth.google && <GoogleLoginButton />}

Fluxo de desenvolvimento local

Quando altera um pacote do CORE e quer testar no BASE antes de publicar, não precisa buildar nem linkar nada. Se o front-cactus-core está clonado ao lado do front-web-base, o Vite / Vitest / tsc do base leem @cactus-agents/* direto do src/*.ts do core — via aliases regex-exatos gerados em runtime pelo helper app/utils/core-alias.ts.

# 1. Editar o fonte no CORE
# (Ex: front-cactus-core/packages/accounts/src/types.ts)

# 2. Rodar o dev/test/typecheck do BASE — lê o source do core automaticamente
cd front-web-base
pnpm dev # HMR sub-segundo, mudanças no core refletem no browser
pnpm typecheck # tsc vê a nova assinatura (via tsconfig.json)
pnpm test # vitest resolve pelo mesmo alias

# 3. Antes de abrir PR, valide contra o pacote publicado:
pnpm dev --registry # força registry, ignora core local
# ou: CACTUS_FORCE_REGISTRY=1 pnpm dev

Como funciona

O helper buildCoreAliases(rootDir, mode) roda em tempo de config e produz:

  • Entradas de alias (regex ancorada ^@cactus-agents/<pkg>$<path>/src/index.ts) pro resolve.alias de Vite/Vitest
  • Entradas de sub-entrypoint (ex: ^@cactus-agents/accounts/react$src/react/index.ts)
  • Lista de moduleNames pra optimizeDeps.exclude

Em tsconfig.json, os paths apontam source-first com fallback pro node_modules (gerados estaticamente pelo script scripts/regen-tsconfig-paths.mjs — rode ao adicionar um package novo no core e comite o resultado). Quando o core não está clonado, TypeScript cai no fallback e resolve pelo registry — mesma config serve pra editor (Cursor/VSCode), dev local e CI.

Produção (pnpm build) ignora os aliases — deploy sempre resolve pelo registry, determinístico.

:::info Quando forçar registry

  • pnpm dev --registry — validar que o pacote publicado ainda funciona (sanity pré-release, repro de bug de CI)
  • pnpm dev --local — oposto: exige source local, falha rápido se o core não estiver clonado (evita fallback silencioso pro registry) :::

:::warning Não altere package.json do base Não aponte @cactus-agents/* para file:../... ou link:../... no package.json. O package.json no git sempre mantém as versões do registry (^0.10.0, etc.) — os aliases source-direct são puramente de tempo de resolução (Vite/tsc/vitest), não tocam node_modules. :::


Checklist para implementar uma nova feature

Antes de escrever qualquer código, responda:

  • O campo da API tem nome/formato diferente do que o front espera? → Criar RawXxx + transform no pacote
  • Existe lógica de "converter X para Y" reutilizável? → Colocar em transform.ts do pacote
  • Há constante de domínio (opções, limites)? → Exportar do pacote, importar no BASE
  • Há validação de formato (CLABE, CPF, etc.)? → Criar em country-config/src/validators/
  • Decisão depende do país? → Configurar em country-config/src/countries/<code>.ts
  • Decisão depende de feature flag da API? → Já está em BrandFeatures — usar useBrand().features
  • O route handler api.*.ts tem lógica além de autenticar e delegar? → Extrair para o pacote

Exemplos: antes e depois

Login History

❌ Antes (lógica no BASE):

// login-history.tsx
interface LoginHistoryItem { // tipo redefinido localmente
date: string; // nome diferente da API
city: string | null;
state: string | null;
}

function formatDate(str: string) {
return new Date(str.replace(" ", "T")).toLocaleString(...); // normalização no componente
}

const location = [item.city, item.state].filter(Boolean).join(", "); // composição no JSX

✅ Depois (CORE faz o trabalho):

import type { LoginHistoryItem } from "@cactus-agents/user"; // tipo correto do SDK

function formatDate(isoStr: string) {
return new Date(isoStr).toLocaleString(intlLocale, ...); // createdAt já é ISO 8601
}

item.location // já composto pelo SDK: "São Paulo, SP"
item.createdAt // já normalizado: "2026-03-17T18:14:42"

Limit Period

❌ Antes:

// Duplicado em 3 arquivos do BASE
const PERIOD_MAP: Record<string, string> = {
daily: "1", weekly: "2", monthly: "3", yearly: "4",
};
const periodValue = PERIOD_MAP[raw] ?? "1";

✅ Depois:

import { parseLimitPeriod } from "@cactus-agents/user";
const periodValue = String(parseLimitPeriod(raw)); // handle tudo: "daily"→1, "1"→1, 2→2

Validação CLABE

❌ Antes:

// ClabeSection.tsx
if (clabe.length !== 18 || !/^\d+$/.test(clabe)) {
setError("CLABE inválida"); // regex inline no componente
}

✅ Depois:

import { validateClabe, stripClabe, CLABE_LENGTH } from "@cactus-agents/country-config";

const clabe = stripClabe(rawValue);
if (!validateClabe(clabe)) {
setError(t("user:account.clabe_invalid")); // SDK valida, componente só renderiza
}