Ajustes de permissões, busca e UX do Blog — Intranet (TEC-544)
Conjunto de correções na Central de Publicações (Blog) da Intranet levantadas na revisão do TEC-544, mais a restauração de uma regressão de sidebar identificada durante a validação (PR #189). Não é uma feature nova — o Blog já existia (TEC-531/TEC-537) — são ajustes de permissão, busca e UX sobre a base existente.
Regressão de sidebar restaurada
O merge do PR #179 (feat(emplacamento): cria relatório por veículo..., commit 3ef3602d) resolveu um conflito em app-sidebar.tsx a favor de uma versão desatualizada da branch, o que:
- Desabilitou os itens Revista UNA, Mercado e Blog no menu da Central de Publicações (voltaram a aparecer como "sem tela nesta milestone", cinza/disabled)
- Removeu a prop
productdoAppSidebare doWorkspaceDropdown(usada porgetTeamAccountSidebarConfig(account, product)pra resolver o link de Configurações por produto)
Restaurado revertendo app-sidebar.tsx pro estado anterior ao merge: os 3 itens voltam a ser links ativos, CENTRAL_PUBLICACOES_DISABLED_ITEMS volta a conter só Enquetes e Eventos (as únicas telas realmente sem implementação nesta milestone), e product volta a ser passado adiante.
Permissões e regras de negócio
- Comunicação só edita/exclui o próprio post. Owner/admin mantêm acesso total a qualquer post do time. Implementado via trigger
enforce_blog_content_own_post_edit(não uma restrição na policyblog_contents_update), porque publicar/despublicar passam pelo mesmoUPDATEemblog_contentse precisam continuar liberados pra qualquer gestor, independente de quem criou o post — uma restrição na policy bloquearia os dois casos juntos. O trigger diferencia "editar/excluir" de "publicar/despublicar" olhando quais colunas mudaram entreOLDeNEW(title,description,content_revision,tags,visibility,thumbnail,deleted_at→ bloqueia;status/content/published_atsozinhos → deixa passar). content_revision(rascunho em andamento) mascarado. A policyblog_contents_selectlibera a linha em statuseditandopra qualquer membro do time (pra quecontent, a versão publicada, continue visível) — mas RLS não restringe coluna. Sem máscara,select('*')na viewblog_contents_overview(usada pela listagem e porlistRelated) vazava o rascunho em andamento pra quem não temcontent.manage. A view agora mascara comCASE WHEN can_manage_blog(account_id) THEN content_revision ELSE null END.- Testes pgTAP novos (
apps/web/supabase/tests/database/blog.test.sql) cobrindo as duas regras acima.
Busca e filtros
- Busca cobre título e conteúdo publicado, não só título. Usa
.or('title.ilike.%termo%,content_text.ilike.%termo%')sobre a colunacontent_text— texto plano espelhandocontent(versão publicada), sincronizado nopublish(). Antes, tentar buscar porcontent::text.ilike...dentro do.or()quebrava a query com erro 400 (PostgREST não aceita cast::tipodentro de um.or(), só em filtros de topo); comcontent_textsendo uma coluna de texto de verdade, o.or()funciona normalmente.- É busca por substring (
ILIKE), não semântica — sem embeddings/pgvector, sem full-text search nativo, sem índice (pg_trgm/GIN) sobretitle/content_text. Aceitável no volume atual; se crescer, considerarpg_trgm+ GIN antes de migrar pra full-text search.
- É busca por substring (
- Filtro por status (Publicados/Rascunho/Excluídos) exposto no filtro avançado da listagem — o service já aceitava
status, faltava a UI. - Fuso do filtro de período corrigido. O front envia datas simples (
"2026-07-27", sem hora); o Postgres/PostgREST interpretam isso como meia-noite UTC, 3h adiantada da meia-noite em Brasília — cortava ~3h de posts do início/fim do intervalo.apps/web/lib/blog/blog-date-range.tsmonta os limites do dia com offset-03:00explícito (Brasil não tem mais horário de verão desde 2019, então o offset fixo é seguro o ano todo).
UX da listagem e do editor
- Categoria obrigatória pra publicar. "Sem categoria" é só o placeholder do
Selectquando nada foi escolhido — não é um item selecionável da lista. Validação emusePostEditorForm.handlePublishClickbloqueia o publish (erro inline no campo + toast) setopicIdestiver vazio. - Listagem não expõe o status interno "editando".
getListingBlogStatus()mapeiaeditando→publicadopra exibição em cards/tabela (o status real no banco continuaeditando, só a apresentação muda).listRelated(posts relacionados) alinhado ao mesmo critério de visibilidade da listagem principal (status in ('publicado', 'editando')). - Header + toolbar sticky unificados. Título, breadcrumb (movidos de
page.tsxpra dentro deBlogListing), abas de visibilidade, busca/filtro/toggle grid-tabela e chips de categoria viraram um único blocosticky top-0, com fundobg-background(mesma cor da página, sem seam) — evita o problema de dois elementos sticky empilhados precisando de offset manual um do outro. O grid/tabela de posts não tem altura contida (max-height) — isso cortava cards inteiros no fundo de uma caixa de scroll própria; a página inteira rola normalmente, com o bloco de topo grudando por cima.- Uma sombra sutil (
shadow-[0_4px_6px_-4px_rgba(0,0,0,0.15)]) aparece só quando o bloco realmente gruda no topo (rolou algo por baixo) — detectado via um sentinel de altura zero logo acima do bloco +IntersectionObserver; sem scroll, ou no topo da página, fica sem sombra.
- Uma sombra sutil (
- Cabeçalho da tabela e toggle grid/tabela alinhados ao protótipo Figma. Cabeçalho da tabela usa o token
bg-abc-card-light-secondary(#edfbff, já existente no design system Abracaf) em vez debg-backgroundpiano. O item ativo do toggle grid/tabela usavariant="default"(bg-primary→--abc-primary-branding, navy#0b2650) em vez devariant="secondary"(cinza-claro, não batia com o protótipo). - Toast duplicado removido. Publicar um post já dispara uma notificação in-app real (
notifyBlogPostPublished, "Novo post publicado" —apps/web/app/[locale]/intranet/_lib/server/notify-blog-published.ts, entregue via RLS a todos os membros do time, inclusive o autor). Otoast.success('Post publicado')manual noBlogPublishDialogduplicava esse feedback — removido, mantendo só a notificação real. - Outros ajustes menores: estado vazio sempre com uma ação ("Novo post" ou "Limpar filtros"); toast de erro quando a atribuição de categoria falha no create/update (best-effort, post não perde a categoria em silêncio); data formatada como "27 jul 2026" (não "27 de jul. de 2026" —
Intl.DateTimeFormatcommonth: 'short'não bate com o protótipo); diálogo de publicação cita o canal real do post (Site e Intranet vs. só Intranet), não um texto fixo.
Decisão de produto pendente
Publicar/despublicar seguem liberados pra qualquer gestor (owner/admin/comunicação), independente de quem criou o post — só editar/excluir é restrito ao autor para o role comunicacao. Está marcado no código (comentário em enforce_blog_content_own_post_edit, apps/web/supabase/schemas/20-blog.sql) como decisão pendente de confirmação: falta decidir se publicar/despublicar devem seguir a mesma regra de "só o próprio post" pra comunicação, ou permanecer liberados como estão.
Arquivos tocados
apps/web/
├── app/[locale]/intranet/
│ ├── _lib/
│ │ ├── blog.utils.ts # getListingBlogStatus, formatBlogDate, ícones por tópico
│ │ ├── schema/blog.schema.ts # filtro de status no schema
│ │ └── server/
│ │ ├── blog.service.ts # busca title+content_text, ordenação, listRelated
│ │ ├── server-actions.ts # expõe filtro de status
│ │ └── notify-blog-published.ts # notificação in-app (pré-existente, referenciada)
│ └── home/[account]/
│ ├── _components/app-sidebar.tsx # regressão restaurada
│ └── blog/
│ ├── [id]/editar/page.tsx
│ ├── page.tsx # PageHeader movido pra dentro de BlogListing
│ ├── _components/ # listing, cards, tabela, diálogos, editor, filtros
│ └── _lib/ # hooks de listagem e do editor
├── lib/blog/
│ ├── blog-date-range.ts # getBrtDayStart/getBrtDayEnd
│ └── __tests__/blog-date-range.test.ts
└── supabase/
├── schemas/20-blog.sql # trigger de permissão, máscara de content_revision
├── migrations/20260729173017_blog_permission_fixes.sql
└── tests/database/blog.test.sql # pgTAP
Referência: PR #189.