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[].
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
| coluna | tipo | nota |
|---|---|---|
escola_id | uuid | FK escolas.id. PK composta. |
feature_key | text | snake_case. PK composta. |
motivo | text | livre, opcional. Exibido no admin. |
expira_em | timestamptz | opcional. Sem efeito automático no backend (apenas informativo). |
criado_por | uuid | usuarios.id admin que aplicou. |
criado_em / atualizado_em | timestamptz | padrão. |
RLS:
SELECT: admin/especialista (todos), papelescola(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.
| coluna | tipo | nota |
|---|---|---|
feature_key | text PK | |
titulo | text | obrigatório. |
subtitulo | text | opcional. |
bullets | jsonb | array de strings, máx 20 itens. |
cta_primaria | text | label do botão principal (default "Assinar agora"). |
cta_secundaria | text | opcional (default "Falar com vendas"). |
imagem_url | text | opcional. |
ativo | boolean | quando 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:
mural, comunicacao, resultados, inscricoes,
formacao_consumo, olimpiadas_coord, controle,
agenda, calendarioON 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% daspermissionKeydeCOORDENADOR_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
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
| action | payload | retorno |
|---|---|---|
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
| action | payload | retorno |
|---|---|---|
list | {} | data: FeatureUpsellCopy[] (inclui inativos) |
upsert | FeatureUpsellCopy | { 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 viafeature_upsell_copy.useFeatureLock(featureKey)— retorna{ locked, featureKey }lendofeaturesBloqueadasdoAuthContext.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.
- Aba Feature Locks dentro de
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:
- Chama
queryClient.invalidateQueries({ queryKey: ['me'] }). - Recarrega
/mepara receber a nova listafeatures_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_assinaturada 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 viarequireAdmin). 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 emstorage.objectsnunca seria atendida — por isso o bucket não tem policies abertas. - Upload (admin):
sign-uploadvalida MIME/size e devolve uma signed upload URL; o navegador fazPUTdireto no Storage com o file binário. - Leitura (qualquer usuário logado): action
get-publicemadmin-feature-upselljá devolve aimagem_urlresolvida como signed URL (TTL 1h). React Query usastaleTime = 55minpara 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_urlcomeçar comhttp(s)://, é tratada como URL externa e exibida direto (sem signed URL).