Skip to content

Trial — Feature Locks

SSOT do sistema de bloqueio seletivo de features durante o trial.

Estado: produção. Última revisão: 2026-05-11.

1. Visão geral

Escolas em trial recebem acesso reduzido: features pagas são exibidas como telas de upsell (<FeatureLockedScreen>), e não como funcionalidade real. A decisão "esta feature está bloqueada" é resolvida no backend e entregue ao frontend no payload do /me, em features_bloqueadas: string[].

text
escola.status_assinatura = 'trial'


trigger fn_trial_apply_locks  (AFTER INSERT/UPDATE em escolas_assinaturas)


RPC apply_default_trial_locks(escola_id)


INSERT em feature_locks  (escola_id, feature_key)


/me  →  features_bloqueadas: ['mural','resultados',...]


Sidebar + roteamento renderizam <FeatureLockedScreen> ao invés da feature.

Papéis isentos (PAPEIS_SEM_LOCK no /me):

  • administrador — opera a plataforma inteira.
  • especialista — transita entre escolas, sempre vê tudo.

Qualquer outro papel (escola, coordenador, diretor) recebe os locks da escola ativa.

2. Modelo de dados

2.1 feature_locks

colunatiponota
escola_iduuidFK escolas.id. PK composta.
feature_keytextsnake_case. PK composta.
motivotextlivre, opcional. Exibido no admin.
expira_emtimestamptzopcional. Sem efeito automático no backend (apenas informativo).
criado_poruuidusuarios.id admin que aplicou.
criado_em / atualizado_emtimestamptzpadrão.

RLS:

  • SELECT: admin/especialista (todos), papel escola (escola_id = jwt.escola_id).
  • INSERT/UPDATE/DELETE: somente admin/especialista (WITH CHECK + USING).

Realtime: REPLICA IDENTITY FULL + publicação supabase_realtime.

2.2 feature_upsell_copy

Copy marketing usada pela <FeatureLockedScreen>. Uma linha por feature_key.

colunatiponota
feature_keytext PK
titulotextobrigatório.
subtitulotextopcional.
bulletsjsonbarray de strings, máx 20 itens.
cta_primariatextlabel do botão principal (default "Assinar agora").
cta_secundariatextopcional (default "Falar com vendas").
imagem_urltextopcional.
ativobooleanquando false, a tela usa fallback genérico.

RLS:

  • SELECT: público autenticado (qualquer usuário renderiza a tela).
  • mutações: somente admin via edge function.

2.3 RPC apply_default_trial_locks(p_escola_id uuid)

SECURITY DEFINER. Insere o conjunto canônico de features pagas:

text
mural, comunicacao, resultados, inscricoes,
formacao_consumo, olimpiadas_coord, controle,
agenda, calendario

ON CONFLICT DO NOTHING — re-execução é idempotente.

Regra de cobertura (SSOT): o catálogo feature_upsell_copy (ativo=true) e o array de defaults DEVEM cobrir 100% das permissionKey de COORDENADOR_SECTIONS (src/lib/navigation/sections-registry.ts). Guardião: src/lib/__tests__/feature-locks-catalog-coverage.test.ts.

2.4 Trigger fn_trial_apply_locks

Disparado em escolas_assinaturas quando status_assinatura muda para trial (ou primeiro INSERT em trial). Chama a RPC acima.

Saída de trial (ativa, inadimplente, cancelada) não limpa locks automaticamente. Decisão consciente: locks viram override manual após o trial — admin remove com clear_all ou linha a linha.

3. Contrato /me

ts
type MeResponse = {
  // ...
  features_bloqueadas: string[];   // ex: ['mural','resultados']
};

Regras:

  • Vazio se principal_role ∈ PAPEIS_SEM_LOCK.
  • Vazio se a escola ativa não tem linhas em feature_locks.
  • Atualizações entram em vigor no próximo /me (login, troca de papel, ou invalidação manual via realtime — ver §6).

4. Edge functions admin

Ambas requerem JWT com principal_role = 'administrador'. verify_jwt = false no config.toml (validação feita por requireAdmin). Respostas seguem o contrato padrão { success, data? | message } com status 401/403/400/200/500.

4.1 admin-feature-locks

actionpayloadretorno
list{ escola_id }{ locks: [], catalog: [] }
upsert{ escola_id, feature_key, motivo?, expira_em? }{ success: true }
delete{ escola_id, feature_key }{ success: true } ou 403
apply_defaults{ escola_id }{ success: true }
clear_all{ escola_id }{ success: true }

Ações de mutação são logadas (trial.lock_criado, trial.lock_atualizado, trial.lock_removido, trial.locks_default_aplicados, trial.locks_limpos).

4.2 admin-feature-upsell

actionpayloadretorno
list{}data: FeatureUpsellCopy[] (inclui inativos)
upsertFeatureUpsellCopy{ success: true }
toggle{ feature_key, ativo }{ success: true }
delete{ feature_key }{ success: true } ou 409 (em uso)

delete retorna 409 se existirem feature_locks referenciando a key — admin deve usar toggle para desativar sem perder o histórico.

5. Frontend

  • <FeatureLockedScreen featureKey={...} fallbackLabel={...} /> — renderiza a tela de upsell, carrega copy via feature_upsell_copy.
  • useFeatureLock(featureKey) — retorna { locked, featureKey } lendo featuresBloqueadas do AuthContext.
  • isFeatureLocked(list, key) — helper imperativo (usado na sidebar para decorar itens travados).
  • Hooks admin:
    • useFeatureLocks(escola_id) — lista/upsert/delete/apply_defaults/clear_all.
    • useFeatureUpsellCopy() — list/upsert/toggle/delete.
  • UI admin:
    • Aba Feature Locks dentro de admin-escola-detalhes.
    • Aba Trial Upsell dentro de admin-comunicacao.

6. Realtime

A tabela feature_locks está em supabase_realtime com REPLICA IDENTITY FULL. O hook useFeatureLocksRealtime (montado no AuthProvider) assina postgres_changes filtrado por escola_id da escola ativa e:

  1. Chama queryClient.invalidateQueries({ queryKey: ['me'] }).
  2. Recarrega /me para receber a nova lista features_bloqueadas.

Papéis isentos (administrador, especialista) não montam a assinatura.

7. Runbook

  • Aplicar defaults para uma escola já em trial: aba Feature Locks → botão "Aplicar defaults". Idempotente.
  • Liberar uma feature individual: aba Feature Locks → ação "Remover" na linha. Efeito propaga em segundos (realtime).
  • Liberar todas (override do trial): botão "Limpar todos". Não muda o status_assinatura da escola.
  • Editar copy de uma feature: aba Trial Upsell → editar linha → salvar. Cache feature-upsell-copy/<key> invalida automaticamente (staleTime 10 min, mas o admin grava ao vivo).
  • Desativar copy sem perder: toggle ativo. Tela passa a usar fallback ("Esta funcionalidade está bloqueada no seu plano atual").

8. Não-objetivos

  • Não controla limites quantitativos (alunos, espaço). Ver mem://features/billing/trial-lifecycle-and-isolation.
  • Não substitui flags de canary (feature_flag_canary).
  • Não decide cobrança/preço. Ver docs/business/SUBSCRIPTION_BILLING.md.

9. Upload de imagens do upsell

  • Bucket: feature-upsell-images (privado, acesso direto negado).
  • Acesso: 100% intermediado pela Edge Function admin-feature-upsell-image (admin-only via requireAdmin). O cliente supabase-js do navegador é anônimo para o PostgREST/Storage (o JWT custom OLP só chega nas Edge Functions via cookie HttpOnly), então RLS direta em storage.objects nunca seria atendida — por isso o bucket não tem policies abertas.
  • Upload (admin): sign-upload valida MIME/size e devolve uma signed upload URL; o navegador faz PUT direto no Storage com o file binário.
  • Leitura (qualquer usuário logado): action get-public em admin-feature-upsell já devolve a imagem_url resolvida como signed URL (TTL 1h). React Query usa staleTime = 55min para revalidar antes da expiração.
  • Delete: delete é idempotente (ignora "not found").
  • Path: {feature_key}/{uuid}.{png|jpg|webp} — UUID evita enumeração.
  • Limites: 2MB, PNG/JPEG/WEBP, recomendado 1280×720.
  • Helpers SSOT: src/lib/feature-upsell-image.ts (uploadUpsellImage, resolveUpsellImage, deleteUpsellImage).
  • Backward-compat: se imagem_url começar com http(s)://, é tratada como URL externa e exibida direto (sem signed URL).