Pular para o conteúdo
KW
Voltar aos projetos

amostra 02/MVP funcional

Simone Educação Digital

Uma plataforma de cursos que trocou a assinatura pela compra única antes do lançamento, sem começar do zero.

  • Next
  • Prisma
  • Stripe
  • Vimeo
  • Turbo

01

Contexto

Simone Mendes é personal organizer e ensina organização em cursos online. A plataforma reúne o site público, a área do aluno com as aulas em vídeo e um painel administrativo.

O projeto nasceu com assinatura mensal. Antes do lançamento, o modelo comercial mudou para compra única com acesso por prazo, e a estrutura precisou acompanhar sem perder o que já estava pronto.

02

Ponto de partida

O MVP começou com assinatura mensal e acesso liberado por nível de plano.

3

níveis de assinatura mensal

2

portais: aluno e administração

0

pagamentos integrados no primeiro dia

03

Linha do tempo

47 commits no histórico do projeto. Estes são os marcos.

  1. 08 de jul. de 2026

    MVP no ar

    Site público, área do aluno e painel num monorepo, publicado na região de São Paulo, perto do banco de dados.

  2. 12 de jul. de 2026

    Vídeo protegido

    Aulas no Vimeo entregues por uma rota que confere o acesso antes de liberar o vídeo, e aulas de cortesia para quem ainda não comprou.

  3. 24 de ago. de 2026

    Pagamentos e refino

    Checkout do Stripe em modo de teste, landing reescrita e limite de requisições no login.

  4. 27 de ago. de 2026

    Mudança de modelo comercial

    De assinatura para compra única com acesso por prazo. O modelo de dados novo entrou ao lado do antigo, sem quebrar nada.

  5. 28 de ago. de 2026

    Acesso por direito

    Pagamento único no Stripe, páginas de cursos e de módulos avulsos, concessão manual de acesso no painel e área do aluno por módulo.

  6. 13 de set. de 2026

    E-mails e virada

    Quatro fluxos de e-mail transacional, cabeçalhos de segurança nos dois portais e remoção do modelo antigo de assinatura.

  7. 14 de set. de 2026

    Hardening de segurança

    Rodadas de auditoria com testes de segurança automatizados cobrindo autenticação, autorização, pagamentos e arquivos privados.

  8. 16 de set. de 2026

    Painel do cliente

    Painel do aluno redesenhado, carrossel de cursos e ajustes finais no checkout.

04

Mapa de decisões

Reconstruído a partir do histórico de commits, do README e da documentação do projeto.

01

Migração aditiva, virada no fim

O modelo novo entrou ao lado do antigo, e as travas de acesso consideravam os dois até a virada. Só então o modelo de assinatura foi removido. A plataforma nunca ficou quebrada no meio do caminho.

02

Acesso é um direito com prazo

Cada compra gera direitos de acesso com data de expiração, e toda trava consulta esse direito. O escopo pode ser um módulo ou tudo, inclusive o conteúdo lançado depois.

03

O vocabulário da cliente

No painel, os nomes seguem a fala da Simone: curso, módulo e aula. O banco mantém os nomes técnicos, e a tradução acontece na interface.

04

O painel edita tudo

Preços, prazos, composição dos cursos, módulos avulsos e vagas são dados, não código. Nada comercial fica fixo no sistema.

05

E-mail nunca derruba o fluxo

O envio de e-mail não lança erro: se falhar, registra e segue. Um e-mail com problema nunca impede um cadastro, uma compra ou um aviso de expiração.

06

Pagamento conferido e processado uma vez

O webhook valida a assinatura do Stripe, confere se valor, moeda e preço batem com o pedido registrado e usa uma trava no banco para o mesmo pagamento nunca virar duas compras.

05

Arquitetura

As mesmas cinco camadas desta página, aplicadas ao projeto.

  1. 000 m/interface

    • Site público e área do aluno
    • Painel administrativo separado
    • Componentes compartilhados
  2. 030 m/rota

    • Proxy com rotas protegidas por papel
    • Limite de requisições em login, cadastro e senha
    • CSP e cabeçalhos de segurança
  3. 060 m/serviço

    • Resolvedor de acesso por direito
    • Checkout e webhook do Stripe
    • E-mails transacionais com Resend
    • Aviso diário de expiração
  4. 090 m/repositório

    • Prisma com Postgres no Supabase
    • Pacotes de auth, banco, e-mail e storage
  5. 120 m/persistência

    • Arquivos privados no Supabase Storage
    • Tokens de redefinição guardados só como hash
    • Senhas com bcrypt

06

Código

Trechos reais do repositório público.

Checagem de posse antes de liberar conteúdo

Estar logado não basta. O acesso exige um direito ativo, de uma compra paga, que cubra aquele módulo. E um usuário bloqueado perde o acesso na hora, sem esperar a sessão expirar.

apps/web/lib/entitlements.tsts
/** Usuário existe e não está bloqueado/excluído? (choke point de bloqueio) */
async function isUserActive(userId: string): Promise<boolean> {
  const u = await prisma.user.findUnique({
    where: { id: userId },
    select: { blockedAt: true, deletedAt: true },
  });
  return Boolean(u && !u.blockedAt && !u.deletedAt);
}

/** Existe entitlement ativo que cobre este módulo (Course)? */
export async function hasCourseEntitlement(
  userId: string,
  courseId: string,
): Promise<boolean> {
  if (!(await isUserActive(userId))) return false;
  const now = new Date();
  const found = await prisma.entitlement.findFirst({
    where: {
      userId,
      expiresAt: { gt: now },
      purchase: { status: 'PAID', accessSuspended: false },
      OR: [{ scope: "ALL" }, { scope: "COURSE", courseId }],
    },
    select: { id: true },
  });
  return Boolean(found);
}

Webhook: assinatura, conferência e trava

A assinatura é validada sobre o corpo bruto, antes de qualquer processamento. Na confirmação, uma trava por pagamento no Postgres garante que duas entregas do mesmo evento resultem numa compra só.

apps/web/app/api/stripe/webhook/route.tsts
export async function POST(req: Request): Promise<NextResponse> {
  const secret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!secret) return NextResponse.json({ error: "not_configured" }, { status: 503 });
  const signature = req.headers.get("stripe-signature");
  if (!signature) return NextResponse.json({ error: "no_signature" }, { status: 400 });
  const rawBody = await req.text();
  if (Buffer.byteLength(rawBody) > 1_048_576) return NextResponse.json({ error: "too_large" }, { status: 413 });
  let event: Stripe.Event;
  try { event = getStripe().webhooks.constructEvent(rawBody, signature, secret); }
  catch { return NextResponse.json({ error: "invalid_signature" }, { status: 400 }); }
  // ... despacha pelo tipo do evento
}

// Na confirmação da compra, depois de conferir valor, moeda e preço com o pedido:
await prisma.$transaction(async tx => {
  await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${"payment:" + paymentId}, 0))::text`;
  const current = await tx.purchase.findUniqueOrThrow({ where: { id: purchaseId } });
  if (current.status !== "PENDING") return;
  // ... marca como paga e materializa os direitos de acesso
});

E-mail à prova de falha

O resultado vem como valor, não como exceção. Quem chama decide o que fazer, e a chave de idempotência evita e-mail em dobro quando o mesmo evento é processado de novo.

packages/email/src/send.tsts
/**
 * Envia um e-mail transacional. À PROVA DE FALHA por design: nunca lança.
 * Se a key não existe ou o Resend falha, loga e retorna { ok: false } — assim
 * um e-mail quebrado JAMAIS derruba o fluxo que o disparou (signup, compra…).
 */
export async function sendEmail(params: {
  to: string;
  subject: string;
  html: string;
  replyTo?: string;
  idempotencyKey?: string;
}): Promise<SendResult> {
  const resend = getResend();
  if (!resend) {
    // ... registra o aviso
    return { ok: false, skipped: true };
  }

  try {
    const { data, error } = await resend.emails.send({
      from: FROM_EMAIL,
      to: params.to,
      subject: params.subject,
      html: params.html,
      replyTo: params.replyTo ?? REPLY_TO,
    }, params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : undefined);

    if (error) {
      // ... registra a recusa
      return { ok: false, error: error.message ?? String(error) };
    }
    return { ok: true, id: data?.id };
  } catch (err) {
    // ... registra a exceção
    return {
      ok: false,
      error: err instanceof Error ? err.message : "erro desconhecido",
    };
  }
}

07

Resultado

21

testes de segurança automatizados

4

fluxos de e-mail transacional

8

pacotes compartilhados no monorepo

2

modelos de acesso em paralelo até a virada

Estado atual

MVP funcional no ar, em fase de pré-lançamento. Cursos, preços e prazos são configurados pelo próprio painel administrativo.