Como colocar a Plataforma Mobiliza no ar
Documento de implementação ponta a ponta: implantar um projeto novo, configurar o tenant, montar e publicar um curso, e a base técnica que sustenta tudo isso. Extraído do código-fonte real da plataforma.
Visão geral da plataforma
A Mobiliza é uma aplicação multi-tenant: vários projetos (Mobiliza Curitiba, Semil Circular, etc.) compartilham o mesmo host, isolados por um slug. Cada projeto tem sua homepage pública, curso, gincana, premiação e marca própria — inclusive instalação como app no celular com nome e ícone do projeto.
A implementação se divide em quatro frentes, e este manual segue exatamente essa ordem:
| Bloco | Quem faz | O que entrega |
|---|---|---|
| A | Master | Cria o projeto/tenant, vincula curso e cadastra editores |
| B | Admin do projeto | Configura branding, PWA, hierarquia, e-mail, gincana, relatórios |
| C | Master / Editor | Monta, revisa e publica o curso; libera módulos por projeto |
| D | Time técnico | Arquitetura, banco, edge functions, deploy — e as regras que não podem ser quebradas |
O lado do participante final (perfis, gamificação, como fazer o curso) já está documentado dentro do próprio app em /guia (src/pages/public/PlatformGuide.tsx). Este manual não repete esse conteúdo — o foco aqui é implantação e operação.
Papéis e permissões
São sete papéis no enum app_role. Vivem em src/contexts/AuthContext.tsx, ProtectedRoute.tsx, ProjectProtectedRoute.tsx e src/App.tsx.
| Papel | Escopo | Acessa |
|---|---|---|
| master | Global (project_id = null) | Tudo. Atalho de autorização em qualquer rota. /master/* |
| admin | Um projeto | /projeto/<slug>/admin/* — exige par project_admins + user_roles |
| editor | Global, sem projeto | Só /editor/cursos — catálogo e editor de curso |
| teacher | Participante | Faz curso/gincana (fora do escopo deste manual) |
| ped_coordinator | Escola | Painel consolidado da unidade |
| collection_coordinator | Escola | Coordena coleta |
| collection_operator | Projeto | Registra coleta em campo (precisa aprovação) |
O usuário precisa já existir (ter feito signup) antes de ser promovido a admin ou editor. A promoção só busca o perfil por e-mail e insere o papel — ela não cria conta. Se o e-mail não tiver perfil, em alguns fluxos a promoção é ignorada silenciosamente (ver Bloco A).
Há uma "graça de hidratação" de 1,5 s antes de declarar acesso negado (a sessão é lida do localStorage de forma assíncrona). Telas de skeleton não são bloqueio — é a sessão carregando. /master/login redireciona para /login (legado): o login é único.
Criar o projeto
Vive em src/components/master/CreateProjectForm.tsx (rota /master/projetos/novo).
- Estar autenticado como master.
- Para já vincular admins na criação, cada e-mail precisa ter conta cadastrada na plataforma (a busca é em
profiles).
- No painel
/master, clicar em "Criar novo projeto". - Preencher os campos do formulário (detalhados abaixo).
- Clicar "Salvar projeto". O sistema insere em
projects, vincula os admins existentes e redireciona para/mastercom toast mostrando o/<slug>.
Campos do formulário
Gera o
slug automaticamente (minúsculo, sem acentos, espaços viram -). A URL pública prevista aparece em tempo real.Validado por
upload-validation.ts; sobe para o bucket project-assets em logos/<slug>.<ext>.Ativo ou Concluído (grava ativo/concluido).Digitar e-mail +
+ (ou Enter). Cada um precisa ter conta. Grava em user_roles (role admin + project_id) e project_admins.- Slug duplicado: erro Postgres
23505→ toast "Já existe um projeto com esse slug". Nomes parecidos colidem. - Admin inexistente é ignorado sem aviso: se o e-mail não tiver perfil, o papel simplesmente não é inserido e nada avisa. Confira depois em ProjectDetails → aba Admins. O caminho de ProjectDetails dá erro explícito; prefira-o.
- Upload de logo que falha não bloqueia a criação — projeto nasce com
logo_urlnulo. - O slug é imutável na prática: mudá-lo depois quebra URLs públicas, manifest PWA, QR codes de coleta e links já distribuídos. Trate nome/slug como definitivos após publicar.
Gerir o projeto e vincular o curso
Vive em src/pages/master/ProjectDetails.tsx (rota /master/projetos/:id), com abas:
- Visão geral: seletor "Curso vinculado" — só cursos
publishedaparecem; grava emprojects.course_id. Botões de status e arquivar/desarquivar. - Redes / Escolas: mesma função do painel admin (Bloco B). Remover rede apaga as escolas em cascata.
- Admins: adicionar por e-mail com feedback explícito ("Usuário não encontrado", "Já é administrador").
- Parceiros: nome + ordem.
Trocar o curso vinculado de um projeto apaga automaticamente todos os overrides de liberação de módulo (project_module_overrides) do curso antigo. Se já havia um cronograma de liberação configurado, ele se perde na troca.
Cadastrar editores de curso
Vive em src/pages/master/EditorManagement.tsx (rota /master/editores).
- Em
/master/editores, digitar o e-mail do usuário e clicar Adicionar. - O sistema busca
profilespor e-mail. Não achou → "Usuário não encontrado". Já é editor → "Este usuário já é editor". - Sucesso: insere
user_rolescomrole: 'editor'(semproject_id).
Um editor vê o catálogo de cursos inteiro, cria/edita/duplica qualquer curso, mas não exclui curso (só master) e não gera links de compartilhamento de preview (o RLS rejeitaria). Não há tela para promover alguém a master — isso é feito no banco.
Configurações do projeto
Vive em src/pages/admin/AdminConfig.tsx (rota /projeto/<slug>/admin/config). Seis abas: Geral, Hierarquia, Homepage, Visual, Parceiros, KPIs.
saveSection()→ grava na tabelaprojects(cores, KPIs, hierarquia, flags de inscrição/coleta).saveHomepageSection()→ grava emproject_homepage_settings(hero, rodapé, SEO, PWA, ordem de blocos).- Uploads de imagem persistem na hora ao subir o arquivo — não precisa clicar "Salvar" do card. Toggles de visibilidade também.
Aba Geral
Nome e Slug são somente leitura. Logotipo (sobe para <slug>/logo-<ts>.<ext>), Status, Camada de coleta (toggle que esconde Coletores/Coord. Coleta do menu quando off) e quatro toggles de "quem pode se inscrever pelo formulário público".
Aba Hierarquia
Renomeia rótulos sem mudar função: Nível 1 (Rede), Nível 2 (Escola), Papel A (Professor), B (Coord. Pedagógico), C (Coord. Coleta), D (Coletor) + a "área de atuação" do Papel A. Preview mostra o efeito no menu/cadastro.
Aba Visual
Cinco cores: Primária, Secundária, Destaque, Texto Principal, Header/Navbar. Preview ao vivo. Salvar com "Salvar cores".
Aba Parceiros
Nome, Categoria (Iniciativa, Realização, Co-realização, Patrocínio, Apoio, Parceiro), Site, logo. Reordena categorias (não parceiros individuais).
Aba KPIs
Metas (taxas de cadastro/início/conclusão, totais) e pesos de evidências de engajamento — esses pesos alimentam o ranking em Relatórios.
Aba Homepage
Cada card persiste em colunas de project_homepage_settings: ordem dos blocos, Hero (foto/mesh/static), overlay, rodapé (3 colunas), SEO/favicon/OG, e os campos PWA (próxima seção). Cada bloco tem toggle de visibilidade.
PWA dinâmico — instalação no celular
Card "App instalável (PWA)" em AdminConfig.tsx, mais src/lib/pwa-icons.ts e a edge function supabase/functions/project-manifest/index.ts.
Todos os projetos compartilham o host, mas cada um deve instalar no celular com a sua própria marca. O manifest.webmanifest é gerado dinamicamente pela edge function lendo quatro campos de project_homepage_settings.
- Nome completo (splash) →
pwa_name. Vazio = nome do projeto. - Nome curto (homescreen) →
pwa_short_name. Máx. 12 caracteres. - Cor do app →
pwa_theme_color. Botão "Usar cor do projeto" copia a primária. - Ícone → PNG quadrado 512×512 ou maior. O upload gera 4 derivados via canvas (
icon-192,icon-512,icon-maskable-512,apple-touch-180) com o mesmo timestamp. - Clicar "Salvar configuração do app".
A edge function recebe só o pwa_icon_url (o 192) e deriva os outros por substituição de nome de arquivo (regex ^(.*)/icon-192-(\d+)\.png$ → icon-512-<ts>.png, icon-maskable-512-<ts>.png).
Nunca cole uma URL de ícone manualmente fora do padrão icon-192-<timestamp>.png. Se o nome não casar com o regex, o manifest fica só com o 192 (sem 512, sem maskable) e a instalação no Android sai com ícone borrado. Sempre use o upload da tela.
- Imagem < 512×512: sobe mas avisa (vira borrão). Sem ícone → manifest sem ícones (instalação genérica).
- Cache do manifest é de 5 minutos. Trocar ícone/cor leva até 5 min para refletir no celular.
- iOS não usa o manifest: precisa "Compartilhar → Adicionar à Tela de Início" manual. As meta tags Apple são injetadas e removidas ao sair do escopo do projeto.
Hierarquia rede → escola → pessoas
Redes e Escolas (AdminSchools.tsx): cadastro manual ou import CSV (botão "Modelo CSV"). Cada escola gera unique_code e dispara geocodificação assíncrona.
Professores / Coordenadores (AdminTeachers, AdminCoordPedagogico, AdminCoordColeta): nome, e-mail, escola. Coord. de Coleta tem checkbox "Mesmo que o Coord. Pedagógico".
Coletores e aprovação (AdminColetores.tsx): entram como pendente. Ao aprovar, dispara automaticamente um magic link para o e-mail apontando para /projeto/<slug>/operador. Botão "Identificação" gera cartão imprimível com QR code real por escola.
- Botão "Adicionar escola" fica desabilitado se não houver nenhuma rede. Ordem obrigatória: rede primeiro.
- No import CSV de escolas, se
nome_redenão casar com nenhuma rede existente, a escola vai para a primeira rede do projeto (fallback silencioso). Confira os vínculos depois. - Erro de magic link na aprovação do coletor é engolido (só
console.warn). Se o coletor não receber, a aprovação foi gravada mesmo assim — reaprovar ou reenviar por campanha. - Camada de coleta desativada bloqueia as telas de coletor/coord. coleta inteiras.
Campanhas e régua de e-mail
Campanhas pontuais (AdminEmailCampaign.tsx): escolher grupos destinatários, escrever (ou gerar com IA), enviar agora ou agendar. Variáveis {{nome}}, {{escola}}, {{link_curso}} etc.
Régua automática (AdminEmailAutomation.tsx): réguas por persona com mensagens em sequência (atraso em dias ou vinculado a etapa do projeto). "Log de Envios" lê email_automation_logs (fonte de verdade).
O histórico exibido em AdminEmailCampaign tem itens seedados hardcoded e vive em estado de componente — não é leitura fiel de email_sends. Para auditar envios automáticos, use o "Log de Envios" da régua. Alterações na régua só vão pro banco ao clicar "Salvar Réguas" (inclusive após "Restaurar padrões").
Gincana e premiação
Gincana (AdminGincana.tsx): configurar (ativa, datas, pontos por kg coletado), criar missões (título, categoria, pontos, multiplicador, exigências de relato/foto/link) e aprovar/rejeitar submissões com feedback ao participante. Existe uma missão de sistema "Coleta" não-excluível.
Premiação (AdminPremiacao.tsx): liberar/revogar certificados (personalizáveis: assinatura, logo parceiro, cidade), troféus (pódio + menções honrosas) e relatório consolidado.
O ranking combina pontos de missão (× multiplicador) + pontos de coleta (kg × fator). Mudar "Pontos por kg" nas configurações da Gincana recalcula o ranking inteiro retroativamente.
Dashboard e relatórios
Dashboard (AdminDashboard.tsx): três camadas — Conversão, Engajamento, Alertas de intervenção. Cada alerta tem botão "Enviar e-mail" que pré-seleciona grupo e filtro.
Relatórios (AdminRelatorios.tsx): três abas (Curso, Engajamento, Coleta), cada uma com Exportar CSV (BOM UTF-8).
Relatório WhatsApp (AdminWhatsAppReport.tsx): template com variáveis resolvidas por dados reais, "Gerar hoje" + "Copiar".
Vivem só em estado de componente — recarregar perde templates customizados; o padrão é re-seedado a cada render. Use para gerar-e-copiar, não como repositório.
Catálogo de cursos
Vive em src/pages/master/CourseCatalog.tsx (rotas /master/cursos e /editor/cursos).
- Novo curso: botão →
/<base>/cursos/novo. - Duplicar: cria cópia
draftclonando módulos → aulas → perguntas. - Publicar/Despublicar: alterna
courses.statusentrepublishededraft. É aqui que se publica — não no editor. - Excluir: só master; bloqueado se o curso estiver vinculado a ≥1 projeto.
Só cursos com status = 'published' aparecem no seletor "Curso vinculado" do projeto (Bloco A). Sequência: criar curso → montar conteúdo → revisar → publicar no catálogo → master vincula ao projeto → admin libera módulos.
Editor de curso
Vive em src/pages/master/CourseEditor.tsx (rota /<base>/cursos/:courseId).
O curso só recebe módulos/aulas depois de criado — a aba "Conteúdo" só aparece quando não é novo.
- Aba Informações: Nome (obrigatório), Descrição, Carga horária (default 40h), Nota mínima (default 70%). "Criar curso" insere em
coursescomodraft. - Aba Conteúdo: "Novo módulo" (só nome). Dentro do módulo, "Adicionar aula".
- Aula: Título + tipo de conteúdo (Vídeo URL, Texto rich, Imagem, Galeria até 20, PDF legado) + duração opcional.
- Perguntas da aula: 1+, cada uma com tipo, enunciado e pontos.
Tipos de pergunta suportados
| Tipo | Como funciona |
|---|---|
objetiva | 2–5 alternativas, marca a correta |
dissertativa | Avaliada por IA (edge evaluate-answer); dica opcional |
ordenacao | Itens na ordem correta (até 8) |
associacao | Pares conceito → definição (até 8) |
lacunas | Texto com ___ + respostas + banco de palavras |
verdadeiro_falso | Resposta correta; se "Falso", opções de correção |
escala_likert | Autoavaliação 1–5, sem certo/errado |
- Perguntas são apagadas e reinseridas a cada save de aula: editar uma aula faz
DELETEde todas aslesson_questionsdela e recria do formulário. Não há edição in-place — IDs antigos das perguntas se perdem todo save. - Só perguntas com enunciado preenchido persistem; vazias são descartadas (badge ⚠️).
- Auto-correção de tipo: se há imagens carregadas mas o tipo não é image/carousel, o save força
image/carousel. Sair desses tipos com imagens pede confirmação ("vai descartar N imagens"). - Curso publicado em uso é editado ao vivo: alerta âmbar mostra os projetos afetados. Editar muda o conteúdo para todos os participantes em andamento.
Preview e revisão pedagógica externa
Dois caminhos, mesma UI (player em previewMode — não grava progresso/XP/streak, não afeta ranking):
- Preview autenticado: botão "Pré-visualizar curso" no editor →
/cursos/:id/preview(master/editor). - Preview público compartilhável: botão "Compartilhar" (só master). Define rótulo + validade, gera token em
course_preview_tokense copia o link. A rota/cursos/:id/preview/:tokené pública; a edge functioncourse-previewvalida server-side com service_role e devolve o curso inteiro.
Tokens são por curso (URL com token de outro curso → course_id_mismatch). O preview público funciona com curso em draft (é justamente para revisão pré-publicação). Revogar (revoked_at) tira o acesso na hora. Erros para o revisor são genéricos (não revelam se o token existiu).
Liberação de módulos por projeto
Vive em src/pages/admin/AdminCurso.tsx e AdminCursoLiberacao.tsx. Nota: AdminModulos.tsx gerencia etapas/fases do projeto (project_stages), não módulos de curso.
Quem vincula o curso ao projeto é o master (Bloco A). O admin do projeto controla, por módulo: Liberar, Bloquear ou Programar liberação (data + hora) — grava em project_module_overrides. Ações em massa incluem "Cronograma sequencial" (data inicial + intervalo).
- A liberação é isolada por projeto: o mesmo curso publicado pode ter calendários diferentes em projetos diferentes.
- O cronograma sequencial substitui programações anteriores de todos os módulos. Horário no fuso do navegador.
- Módulo bloqueado trava a conclusão do curso para o participante. Checklist de fim de projeto deve incluir "liberar módulos pendentes".
Arquitetura
SPA Vite + React 18 + TypeScript, Tailwind/shadcn no front, Supabase como backend completo (Postgres + Auth + Storage + Edge Functions Deno). Deploy gerido pelo Lovable.
- Cliente Supabase (
src/integrations/supabase/client.ts): único, tipado portypes.ts(gerado — nunca editar à mão; o Lovable regenera após cada migration). Usa a anon/publishable key — o isolamento é todo via RLS. - AuthContext: fonte de verdade de identidade e papéis. Master tem
project_id = nulle acesso global. Fallback via RPCis_master. - GamificationContext: estado no
localStoragenamespaced por slug e resetado na troca de projeto — a gamificação nunca vaza entre tenants. - Guardas de rota:
ProtectedRoute(auth + papel global, tri-state com grace de 1,5 s) eProjectProtectedRoute(resolve slug→id e escopa o papel ao tenant).
| Área | Rota base | Guarda |
|---|---|---|
| Master | /master | ProtectedRoute · master |
| Editor | /editor | ProtectedRoute · editor |
| Preview por token | /cursos/:id/preview/:token | sem guarda (valida server-side) |
| Admin do projeto | /projeto/:slug/admin | ProjectProtectedRoute · admin |
| Públicas | /projeto/:slug, /guia… | nenhuma |
Banco de dados e migrations
São 101 migrations em supabase/migrations/. As escritas à mão recebem nome semântico e um *.README.md companheiro com prompt, validação e rollback.
RLS habilitado em todas as tabelas de domínio. Padrão de policy: master faz tudo (is_master), admin gere o próprio projeto (is_project_admin), participante vê o que é do seu projeto (get_user_project_ids), dono vê o próprio registro. Funções de segurança são SECURITY DEFINER para evitar recursão de RLS.
Migration destacada — operator_security_hardening (20260513100000)
Fechou dois gaps cross-tenant: (1) operador aprovado no projeto A podia gravar coleta com escola do projeto B — nova policy valida que project_id/school_id da linha batem com o vínculo do operador; (2) bucket Storage project-assets aberto a qualquer authenticated — nova policy extrai project_id do path (storage.foldername(name)[1]) e exige vínculo. Idempotente.
Push de um .sql em supabase/migrations/ NÃO altera o banco de produção. A migration só roda quando é pedida explicitamente em prompt ao Lovable. Padrão obrigatório: migration idempotente + *.README.md com (1) prompt pronto pro Lovable, (2) SQL de validação em information_schema, (3) SQL de rollback. Pedir, no mesmo prompt, a regeneração de types.ts.
Edge functions
10 funções Deno + _shared/. verify_jwt = false declarado para as públicas (auth-email-hook, evaluate-answer, geocode-address, course-preview, project-manifest) — elas fazem a própria validação.
| Função | Propósito | Trigger |
|---|---|---|
project-manifest | Manifest PWA dinâmico por slug | GET ?slug= |
enroll-participant | Matrícula pública; cria auth user + role; re-valida flags server-side | POST do form |
evaluate-answer | Corrige dissertativa via AI Gateway Lovable | POST do player |
course-preview | Preview por token (service_role, valida tudo) | POST público |
auth-email-hook | Renderiza e envia e-mails de auth com marca do projeto | Webhook assinado |
process-email-queue | Worker pgmq → envia (anti-duplicata + rate-limit) | pg_cron |
process-email-automations | Dispara a régua por projeto | pg_cron diário |
send-project-email | Enfileira (não envia direto) | POST interno |
generate-whatsapp-message | Gera texto WhatsApp por IA | POST admin |
geocode-address | Lat/long de escolas (Nominatim) | POST |
Vivem só como secrets no Supabase, nunca no repo: SUPABASE_SERVICE_ROLE_KEY (bypassa RLS — crítico), LOVABLE_API_KEY (AI Gateway/e-mail/webhook), SUPABASE_URL, opcional LOVABLE_SEND_URL. Envio de e-mail é assíncrono via pgmq: produtores enfileiram, só process-email-queue envia. Não chamar envio direto de fluxos novos.
Deploy e ambiente
Lovable e o repo são bidirecionais. .env commitado só tem chave pública (anon key — isolamento é via RLS); os segredos reais ficam só nos secrets das edge functions.
git clone <git-url>
cd escolascirculares
npm i
npm run dev # Vite em http://localhost:8080
O front local aponta para o Supabase de produção (não há Supabase local no repo). Edge functions em dev usam as já deployadas em produção.
Código de app deploya automaticamente; schema de banco NÃO. Sempre que uma feature depender de coluna/tabela nova, o deploy do código vai ao ar antes da migration — por isso o código faz casts defensivos (ex.: SELECT * em vez de campos pwa_*). Coordene: peça a migration ao Lovable antes ou junto de subir o código que depende dela.
Regras críticas de operação
- Migration não é automática no merge. Sempre prompt explícito ao Lovable + README idempotente com validação e rollback + regenerar
types.ts. - Segredos.
service_roleeLOVABLE_API_KEYsó como secrets do Supabase. Endpoint novo comservice_roleprecisa validar o tenant manualmente. - Multi-tenant por slug.
projects.slugé a raiz do isolamento. Auditar policies novas contra escrita/leitura cross-tenant — inclusive Storage (path…/{project_id}/…). - Padrão do ícone PWA.
pwa_icon_urldeve apontar para…/icon-192-<ts>.png. Nunca colar URL fora do padrão. - Filas de e-mail. Produtores enfileiram; só o worker cron envia. Respeitar anti-duplicata e cooldown.
verify_jwtpor função. Função pública nova decide overify_jwtconscientemente e implementa validação própria.course_modules.course_idé o link canônico curso↔módulo;project_idali é legado — não usar para lógica nova.
Mapa arquivo → função
| Tema | Arquivo |
|---|---|
| Criar projeto | src/components/master/CreateProjectForm.tsx |
| Detalhe/edição projeto, vincular curso | src/pages/master/ProjectDetails.tsx, useProjectDetail.ts |
| Cadastro de editores | src/pages/master/EditorManagement.tsx |
| Papéis/permissões | AuthContext.tsx, ProtectedRoute.tsx, ProjectProtectedRoute.tsx |
| Config do tenant | src/pages/admin/AdminConfig.tsx |
| Manifest PWA | supabase/functions/project-manifest/index.ts, src/lib/pwa-icons.ts |
| Redes/escolas/pessoas | AdminSchools, AdminTeachers, AdminColetores… |
AdminEmailCampaign.tsx, AdminEmailAutomation.tsx | |
| Gincana/premiação | AdminGincana.tsx, AdminPremiacao.tsx |
| Editor/catálogo de curso | CourseEditor.tsx, CourseCatalog.tsx |
| Preview de curso | CoursePreview.tsx, supabase/functions/course-preview/ |
| Liberação de módulo | AdminCurso.tsx, AdminCursoLiberacao.tsx |