PersonalVirtual

Documentação Técnica e Roadmap — SaaS B2B2C para Personal Trainers

Next.js + PostgreSQLStripeR$ 69,90/mês

PersonalVirtual — Documentação Técnica e Roadmap de Implementação

Versão: 1.0
Status: Planejamento/Arquitetura
Modelo de negócio: SaaS B2B2C para Personal Trainers
Preço: R$ 69,90/mês (Personal)
Proposta de valor: Acompanhamento granular de treinos + curadoria de produtos via afiliados.


1. Visão Geral da Arquitetura

1.1 Stack proposta

Camada Tecnologia Justificativa
Frontend + API Next.js 14+ (App Router) SSR para SEO de landing, ISR para páginas públicas, API Routes/BFF para não duplicar camada de backend
Banco de dados PostgreSQL 16 Relacional puro; histórico de execuções exige joins e agregações temporais
ORM Prisma ORM Type-safety, migrations versionadas e bom suporte a enums
Autenticação Personal Auth.js (NextAuth v5) — Credentials (Email/Senha) Padrão, com sessão httpOnly
Acesso do Aluno Hash Unique Token (URL dinâmica) Sem cadastro; token na URL (/a/<token>) — sem fricção
Pagamentos Stripe Checkout + Subscriptions + Webhooks Assinatura recorrente R$ 69,90
PDF @react-pdf/renderer Geração de ficha planejada no servidor com templates React
Analytics Agregação SQL + painel próprio Sem dependência extra no MVP; evolução para PostHog
Deploy Vercel (app) + Neon/Supabase (Postgres) Serverless-friendly, webhooks Stripe como Serverless Functions

1.2 Monorepo (estrutura de pastas)

personalvirtual/
├── apps/
   └── web/                      # Next.js (Painel Personal + Área do Aluno)
       ├── app/
          ├── (landing)/        # Landing page pública
          ├── (auth)/           # Login/Registro do Personal
          ├── painel/           # Área Desktop do Personal (autenticada)
          └── a/[token]/        # Área Mobile do Aluno (hash, pública)
       └── src/
           ├── components/
           ├── lib/              # prisma, stripe, auth
           └── server/           # services (domínio)
├── packages/
   ├── db/                       # Prisma schema + migrations
   └── shared/                   # Tipos e utilidades compartilhadas
└── docs/

1.3 Modelo de autorização resumido

Personal  -> sessão autenticada (Auth.js)   -> acesso ao /painel
Aluno     -> hash token (NONCE de 32+ chars) -> acesso a /a/:token
Assinatura do Personal deve estar "active" para o sistema liberar recursos.

2. Schema do Banco de Dados (PostgreSQL)

2.1 Diagrama ER (visão geral)

personals 1───* alunos
alunos    1───* treinos
treinos   1───* treino_exercicios
treino_exercicios 1───* execucoes
exercicios (catálogo, 1─* treino_exercicios)
personals 1───* recomendacoes_produtos
personals 1───* assinaturas
recomendacoes_produtos 1───* cliques_afiliados

2.2 DDL — Prisma Schema (equivalente)

// packages/db/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum StatusAssinatura {
  TRIAL          // trial 7 dias
  ACTIVE         // ativa
  PAST_DUE       // cartão com falha
  CANCELED       // cancelada
  INCOMPLETE     // checkout não finalizado
}

enum DiaDaSemana {
  SEGUNDA
  TERCA
  QUARTA
  QUINTA
  SEXTA
  SABADO
  DOMINGO
}

model Personal {
  id            String   @id @default(cuid())
  nome          String
  email         String   @unique
  senhaHash     String   // Argon2id
  cref          String?
  avatarUrl     String?
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  alunos             Aluno[]
  assinaturaAtualId  String? @unique
  assinaturaAtual    Assinatura? @relation("AssinaturaAtual", fields: [assinaturaAtualId], references: [id])
  assinaturas        Assinatura[] @relation("HistoricoAssinaturas")
  recomendacoes      RecomendacaoProduto[]
}

model Aluno {
  id              String  @id @default(cuid())
  personalId      String
  personal        Personal @relation(fields: [personalId], references: [id])
  nome            String
  apelido         String?  // usado na URL amigável
  email           String?
  telefone        String?
  dataNascimento  DateTime?
  hashToken       String  @unique // NONCE: crypto.randomBytes(32).toString('hex')
  ativo           Boolean @default(true)
  observacoes     String?
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  treinos Treino[]

  @@index([personalId])
}

model Exercicio {
  id          String  @id @default(cuid())
  nome        String
  grupo       String? // Peito, Costas, Pernas...
  imagemUrl   String?
  videoUrl    String?
  criadoPor   String? @db.Uuid // personal que cadastrou (null = catálogo global)

  treinoExercicios TreinoExercicio[]
}

model Treino {
  id             String       @id @default(cuid())
  personalId     String
  alunoId        String
  titulo         String       // "Treino A — Peito e Tríceps"
  diaSemana      DiaDaSemana?
  dataAgendada   DateTime?    // treino avulso/datado
  ordem          Int          @default(0) // ordenação da semana
  ativo          Boolean      @default(true)
  createdAt      DateTime     @default(now())
  updatedAt      DateTime     @updatedAt

  personal Personal @relation(fields: [personalId], references: [id])
  aluno   Aluno    @relation(fields: [alunoId], references: [id])

  exercicios TreinoExercicio[]

  @@index([alunoId, dataAgendada])
  @@index([personalId])
}

model TreinoExercicio {
  id              String @id @default(cuid())
  treinoId        String
  exercicioId     String
  ordem           Int    // ordem de execução
  seriesPlanejadas Int   // ex.: 4
  repeticoes      Int?   // ex.: 10 (ou null p/ falha)
  cargaSugeridaKg Decimal? @db.Decimal(6,2)
  descansoSeg     Int?   @default(90)
  anotacoes       String?

  treino   Treino    @relation(fields: [treinoId], references: [id])
  exercicio Exercicio @relation(fields: [exercicioId], references: [id])
  execucoes Execucao[]

  @@unique([treinoId, exercicioId, ordem])
}

model Execucao {
  id                 String   @id @default(cuid())
  treinoExercicioId  String
  serieNumero        Int      // 1..seriesPlanejadas
  concluida          Boolean  @default(false)
  cargaExecutadaKg   Decimal? @db.Decimal(6,2)
  repsExecutadas     Int?
  duracaoSeg         Int?
  observacao         String?
  realizadaEm        DateTime @default(now())

  treinoExercicio TreinoExercicio @relation(fields: [treinoExercicioId], references: [id])

  @@unique([treinoExercicioId, serieNumero])
  @@index([treinoExercicioId, realizadaEm])
}

model Assinatura {
  id              String          @id @default(cuid())
  personalId      String
  stripeCustomerId String?
  stripeSubscriptionId String?     @unique
  plano           String          @default("MENSAL") // MENSAL, ANUAL
  status          StatusAssinatura @default(TRIAL)
  precoEmCentavos Int
  moeda           String          @default("brl")
  dataInicio      DateTime        @default(now())
  dataFim         DateTime?
  canceladoEm     DateTime?
  dataProximaCobranca DateTime?

  personal Personal @relation("HistoricoAssinaturas", fields: [personalId], references: [id])
  atualDe  Personal? @relation("AssinaturaAtual")

  @@index([personalId])
}

model RecomendacaoProduto {
  id          String  @id @default(cuid())
  personalId  String
  titulo      String      // "Whey da Growth"
  descricao   String?
  linkAfiliado String    // link com tag de afiliado (amzn.to/?tag=..., growth.ref=...)
  imagemUrl   String?
  categoria   String?     // suplementos, acessorios, roupas
  ordenacao   Int         @default(0)
  ativo       Boolean     @default(true)
  createdAt   DateTime    @default(now())
  updatedAt   DateTime    @updatedAt

  personal Personal @relation(fields: [personalId], references: [id])
  cliques  CliqueAfiliado[]

  @@index([personalId, ativo])
}

model CliqueAfiliado {
  id        String   @id @default(cuid())
  recomendacaoId String
  alunoId   String?  // anônimo se null
  ipHash    String?  // hash para contagem anônima
  createdAt DateTime @default(now())

  recomendacao RecomendacaoProduto @relation(fields: [recomendacaoId], references: [id])

  @@index([recomendacaoId, createdAt])
}

2.3 Decisões de modelagem

2.4 Migrations


3. Rotas da API

3.1 Autenticação (Personal)

Método Rota Descrição
POST /api/auth/register Cria Personal + assinatura em TRIAL + cliente Stripe
POST /api/auth/login Login Credentials (Auth.js)
POST /api/auth/logout Encerra sessão
GET /api/auth/session Sessão atual
POST /api/auth/change-password Troca de senha

3.2 Gestão de Alunos e Treinos (Painel Personal — autenticado)

Método Rota Descrição
GET /api/personal/alunos Lista alunos (com hashToken p/ compartilhar)
POST /api/personal/alunos Cria aluno (gera hashToken)
PATCH /api/personal/alunos/:id Edita aluno
POST /api/personal/alunos/:id/regenerate-token Regenera hashToken (revoga antigo)
GET /api/personal/alunos/:id/treinos Treinos do aluno
POST /api/personal/alunos/:id/treinos Cria treino (com exercícios)
PATCH /api/personal/treinos/:treinoId Atualiza treino
DELETE /api/personal/treinos/:treinoId Remove treino
POST /api/personal/treinos/:treinoId/exercicios Adiciona exercício ao treino
GET /api/personal/alunos/:id/analytics Aderência do aluno (séries puladas)

3.3 Área do Aluno — rota pública protegida por hash (NÃO requer sessão)

Todas as rotas abaixo validam :hashToken; o lookup retorna alunoId + personalId e nunca expõe dados de outros alunos.

Método Rota Descrição
GET /api/p/a/:hashToken Dados básicos + treino do dia
GET /api/p/a/:hashToken/treino-do-dia Treino agendado p/ hoje + progresso
GET /api/p/a/:hashToken/treinos/:treinoId Detalhe do treino (exercícios + séries)
POST /api/p/a/:hashToken/treinos/:treinoId/execucoes Marca série: { treinoExercicioId, serieNumero, concluida, ... }
POST /api/p/a/:hashToken/treinos/:treinoId/concluir Marca treino como concluído (valida lógica hierárquica)
GET /api/p/a/:hashToken/historico Histórico de execuções do aluno
GET /api/p/a/:hashToken/recomendacoes "Dicas do seu Personal" (links curados)
POST /api/p/a/:hashToken/recomendacoes/:id/clique Registra clique (incrementa analytics)
GET /api/p/a/:hashToken/exportar-ficha Gera/baixa PDF da ficha planejada

Segurança da rota pública:
- Token com entropia de 256 bits (crypto.randomBytes(32)).
- Rate limiting (ex.: 10 req/min por IP em /api/p/a/*).
- Logs não expõem o token em queries/URLs de log.
- Cache-Control: private para que o token não vaze via CDN.

3.4 Afiliados (Painel Personal)

Método Rota Descrição
GET /api/personal/recomendacoes Lista links cadastrados
POST /api/personal/recomendacoes Cadastra produto/link (valida URL)
PATCH /api/personal/recomendacoes/:id Edita
DELETE /api/personal/recomendacoes/:id Remove
GET /api/personal/recomendacoes/:id/cliques Analytics de cliques (últimos 30 dias)
GET /api/personal/afiliados/resumo Total de cliques, CTR, receita estimada

3.5 Assinatura e Webhooks

Método Rota Descrição
POST /api/assinatura/checkout Cria sessão de Checkout do Stripe e redireciona
GET /api/assinatura/status Status da assinatura do Personal logado
POST /api/webhooks/stripe Recebe eventos (ver seção 5)
POST /api/assinatura/cancel Cancela (via portal de billing)

3.6 Middleware de rota (Next.js)

// middleware.ts — proteção do /painel
export async function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  if (pathname.startsWith('/painel')) {
    const session = await getToken({ req, secret: process.env.AUTH_SECRET });
    if (!session) return NextResponse.redirect(new URL('/login', req.url));

    const status = await getAssinaturaStatus(session.sub);
    if (status !== 'ACTIVE' && status !== 'TRIAL') {
      return NextResponse.redirect(new URL('/assinatura/vencida', req.url));
    }
  }
  return NextResponse.next();
}

export const config = {
  matcher: ['/painel/:path*'],
};

4. Estrutura do Frontend

4.1 Princípios de UX

4.2 Árvore de componentes (Painel do Personal)

app/painel/
├── layout.tsx                    # Sidebar + topbar (Desktop)
├── page.tsx                      # Dashboard (indicadores de aderência)
├── alunos/
   ├── page.tsx                  # Lista de alunos
   ├── novo/page.tsx
   └── [alunoId]/
       ├── page.tsx              # Detalhe + treinos
       └── treinos/[treinoId]/edit/page.tsx
└── recomendacoes/
    ├── page.tsx                  # CRUD de links (lista + form)
    └── [id]/analytics/page.tsx   # Cliques por período

src/components/personal/
├── AlunoTable.tsx
├── TreinoEditor.tsx              # Drag & drop de exercícios
├── ExercicioPicker.tsx
├── FichaPreview.tsx              # Prévia da ficha p/ PDF
├── AderenciaChart.tsx
└── LinkForm.tsx

4.3 Árvore de componentes (Área do Aluno)

app/a/[token]/
├── layout.tsx                    # Tema escuro, header com modo escuro + ícone "Extras"
├── page.tsx                      # Hoje (treino do dia)
├── treinos/[treinoId]/page.tsx   # Execução (checklist de séries)
└── extras/page.tsx               # Aba de Recomendações ("Dicas do seu Personal")

src/components/aluno/
├── TreinoCard.tsx                # Resumo do dia
├── ExercicioChecklist.tsx        # Séries em checklist com botões grandes
├── SerieRow.tsx                  # Linha de série (marca concluída / pula)
├── ProgressRing.tsx / ProgressBar.tsx
├── ExtrasDrawer.tsx              # Acesso secundário às recomendações
└── RecommendationCard.tsx

4.4 Posicionamento da aba de recomendações (UX)

┌─────────────────────────────┐
 [Logo]  [Treino]  [Extras 🔧]  <- ícone secundário, pequeno
├─────────────────────────────┤
  TREINO DO DIA              
  [barra de progresso]       
   Peito                    
     Supino: ▢▢▢▢           
     Crucifixo: ▢▢▢         
  [Concluir Treino]          
└─────────────────────────────┘

5. Fluxo Stripe (Assinatura)

5.1 Diagrama de sequência (checkout)

Personal                         Next.js/API                        Stripe
   |  1. POST /api/assinatura/checkout  |                              |
   |----------------------------------->| 2. createCheckoutSession     |
   |                                    |----------------------------->|
   |                                    | 3. session (client_reference_id) |
   |  4. redirect para checkout         |<-----------------------------|
   |<-----------------------------------|                              |
   |  5. Usuário paga                   |                              |
   |                                    | 6. webhook checkout.session.completed |
   |                                    |<-----------------------------|
   |                                    | 7. atualiza Assinatura=ACTIVE + stripeSubscriptionId
   |  8. GET /api/assinatura/status     |                              |
   |----------------------------------->| 9. retorna ACTIVE            |

5.2 Eventos de webhook tratados

Evento Ação
checkout.session.completed Cria/atualiza Assinatura → ACTIVE, grava stripeCustomerId e stripeSubscriptionId
customer.subscription.updated Sincroniza status (ACTIVE / PAST_DUE / CANCELED), dataProximaCobranca
customer.subscription.deleted Status → CANCELED, canceladoEm
invoice.payment_failed Status → PAST_DUE; envia e-mail de cobrança
invoice.payment_succeeded Status → ACTIVE

5.3 Controle de acesso (assinatura ativa)

[requisição no /painel ou rota do sistema]
        
        
Assinatura.status ∈ { TRIAL, ACTIVE } ?
        │                    │
       Sim                  Não
        │                    │
   PERMITE USO        Redireciona p/ página
                      "Assinatura vencida" +
                      link do Portal de Billing

5.4 Validação de assinatura no servidor

// src/server/services/assinatura.ts
export async function hasAcessoAtivo(personalId: string): Promise<boolean> {
  const assinatura = await prisma.assinatura.findFirst({
    where: {
      personalId,
      status: { in: ['TRIAL', 'ACTIVE'] },
    },
    orderBy: { dataInicio: 'desc' },
  });
  if (!assinatura) return false;

  // confere com o Stripe para desktops e mudanças fora do webhook
  const sub = await stripe.subscriptions.retrieve(assinatura.stripeSubscriptionId);
  return ['trialing', 'active'].includes(sub.status);
}

6. Snippets TypeScript

6.1 Cálculo de progresso do treino (lógica hierárquica)

// src/server/services/treino.ts
import type { TreinoExercicio, Execucao } from '@personalvirtual/db';

type Item = TreinoExercicio & { execucoes: Execucao[] };

export interface ProgressoTreino {
  treinoId: string;
  totalSeries: number;
  concluidas: number;
  percentual: number;
  exercicios: Array<{
    treinoExercicioId: string;
    concluido: boolean; // todas as séries deste exercício concluídas
    seriesConcluidas: number;
  }>;
  treinoConcluido: boolean; // TODAS as séries de TODOS os exercícios
}

export function calcularProgresso(
  treinoId: string,
  exercicios: Item[],
): ProgressoTreino {
  let totalSeries = 0;
  let concluidas = 0;

  const mapeados = exercicios.map((te) => {
    const feitas = te.execucoes.filter((e) => e.concluida);
    const exercicioConcluido = feitas.length >= te.seriesPlanejadas;

    totalSeries += te.seriesPlanejadas;
    concluidas += Math.min(feitas.length, te.seriesPlanejadas);

    return {
      treinoExercicioId: te.id,
      concluido: exercicioConcluido,
      seriesConcluidas: feitas.length,
    };
  });

  const percentual = totalSeries === 0 ? 0 : Math.round((concluidas / totalSeries) * 100);

  return {
    treinoId,
    totalSeries,
    concluidas,
    percentual,
    exercicios: mapeados,
    treinoConcluido: concluidas >= totalSeries && totalSeries > 0,
  };
}

6.2 Renderização das recomendações (Aba "Dicas do seu Personal")

// src/components/aluno/ExtrasDrawer.tsx
'use client';

import { useEffect, useState } from 'react';
import type { RecomendacaoProduto } from '@personalvirtual/db';

interface Props {
  token: string;
  alunoId: string;
  onClose: () => void;
}

export function ExtrasDrawer({ token, alunoId, onClose }: Props) {
  const [recomendacoes, setRecomendacoes] = useState<RecomendacaoProduto[]>([]);
  const [carregando, setCarregando] = useState(true);

  useEffect(() => {
    fetch(`/api/p/a/${token}/recomendacoes`)
      .then((r) => r.json())
      .then(setRecomendacoes)
      .finally(() => setCarregando(false));
  }, [token]);

  const aoClicar = async (id: string, link: string) => {
    // registra o clique sem bloquear a navegação
    fetch(`/api/p/a/${token}/recomendacoes/${id}/clique`, { method: 'POST' }).catch(() => {});
    window.open(link, '_blank', 'noopener,noreferrer');
  };

  return (
    <aside className="drawer" role="dialog" aria-label="Dicas do seu Personal">
      <header>
        <h2>Dicas do seu Personal</h2>
        <button onClick={onClose} aria-label="Fechar">×</button>
      </header>

      {carregando && <p className="muted">Carregando</p>}

      <ul className="lista-recomendacoes">
        {recomendacoes.map((r) => (
          <li key={r.id}>
            <button
              className="recommendation-card"
              onClick={() => aoClicar(r.id, r.linkAfiliado)}
            >
              {r.imagemUrl && <img src={r.imagemUrl} alt="" loading="lazy" />}
              <div>
                <strong>{r.titulo}</strong>
                {r.descricao && <p>{r.descricao}</p>}
                <span className="badge">{r.categoria}</span>
              </div>
            </button>
          </li>
        ))}
      </ul>
    </aside>
  );
}

7. Analytics de Aderência

Query principal — identificar exercícios/séries pulados por aluno:

SELECT
  ex.nome AS exercicio,
  COUNT(*) FILTER (WHERE NOT e.concluida) AS series_puladas,
  COUNT(*) AS series_planejadas,
  ROUND(100.0 * COUNT(*) FILTER (WHERE e.concluida) / COUNT(*), 1) AS aderencia_pct
FROM "TreinoExercicio" te
JOIN "Exercicio" ex ON ex.id = te."exercicioId"
LEFT JOIN "Execucao" e ON e."treinoExercicioId" = te.id
  AND e."realizadaEm" >= now() - interval '30 days'
WHERE te."treinoId" = ANY(:treinosDoAluno)
GROUP BY ex.id, ex.nome
ORDER BY series_puladas DESC;

8. Estratégia de Monetização via Afiliados

8.1 Modelo de receita

Fonte Comissão típica (Brasil) Papel da plataforma
Assinatura R$ 69,90 100% (receita principal) Plataforma
Afiliado — Suplementos (Growth, IntegralMédica, DarkLab) 5–12% Personal cadastra link com ref e ganha comissão
Afiliado — Amazon (suplementos, acessórios, roupas) ~2–5% Tag de afiliado do Personal
Marketplace (próxima fase) 10–15% de take rate da plataforma Comissão intermediada pela plataforma

8.2 Como a plataforma se beneficia

  1. Diferencial de retenção: o Personal usa a plataforma para monetizar sua audiência — vira parte do fluxo de trabalho, reduz churn.
  2. Dados de curadoria: saber quais produtos convertem ajuda a negociar com marcas.
  3. Up-sell futuro: "Catálogo premium" onde a plataforma negocia comissões maiores em troca de volume (take rate).

8.3 Experiência non-invasive

8.4 Compliance


9. Roadmap de Desenvolvimento

Fase 0 — Fundação (Semanas 1–2)

Fase 1 — Núcleo do Produto (Semanas 3–5)

Fase 2 — Monetização (Semanas 6–7)

Fase 3 — Analytics e PDF (Semanas 8–9)

Fase 4 — Hardening (Semanas 10–12)

Fase 5 — Escala (Pós-MVP)


10. Riscos e Mitigações

Risco Mitigação
Churn do Personal TRIAL curto + valor de monetização afiliada como gancho
Token exposto/roubado Entropia 256 bits, rate limit, regeneração, HTTPS-only
Aluno não conclui treinos UX de 1 toque por série, lembretes, barra de progresso
Webhook Stripe falhando Idempotência por evento, fila de reprocessamento (retry + DLQ)
LGPD (dados do aluno) Dados mínimos, clique anônimo, política de privacidade
Dependência de comissões baixas A receita principal é a assinatura; afiliados são retenção + margem