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.
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.
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.
24 de ago. de 2026
Pagamentos e refino
Checkout do Stripe em modo de teste, landing reescrita e limite de requisições no login.
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.
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.
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.
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.
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.
000 m/interface
- Site público e área do aluno
- Painel administrativo separado
- Componentes compartilhados
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
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
090 m/repositório
- Prisma com Postgres no Supabase
- Pacotes de auth, banco, e-mail e storage
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.
/** 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ó.
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.
/**
* 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.