Pular para o conteúdo principal

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 product do AppSidebar e do WorkspaceDropdown (usada por getTeamAccountSidebarConfig(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 policy blog_contents_update), porque publicar/despublicar passam pelo mesmo UPDATE em blog_contents e 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 entre OLD e NEW (title, description, content_revision, tags, visibility, thumbnail, deleted_at → bloqueia; status/content/published_at sozinhos → deixa passar).
  • content_revision (rascunho em andamento) mascarado. A policy blog_contents_select libera a linha em status editando pra qualquer membro do time (pra que content, a versão publicada, continue visível) — mas RLS não restringe coluna. Sem máscara, select('*') na view blog_contents_overview (usada pela listagem e por listRelated) vazava o rascunho em andamento pra quem não tem content.manage. A view agora mascara com CASE 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 coluna content_text — texto plano espelhando content (versão publicada), sincronizado no publish(). Antes, tentar buscar por content::text.ilike... dentro do .or() quebrava a query com erro 400 (PostgREST não aceita cast ::tipo dentro de um .or(), só em filtros de topo); com content_text sendo 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) sobre title/content_text. Aceitável no volume atual; se crescer, considerar pg_trgm + GIN antes de migrar pra full-text search.
  • 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.ts monta os limites do dia com offset -03:00 explí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 Select quando nada foi escolhido — não é um item selecionável da lista. Validação em usePostEditorForm.handlePublishClick bloqueia o publish (erro inline no campo + toast) se topicId estiver vazio.
  • Listagem não expõe o status interno "editando". getListingBlogStatus() mapeia editandopublicado pra exibição em cards/tabela (o status real no banco continua editando, 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.tsx pra dentro de BlogListing), abas de visibilidade, busca/filtro/toggle grid-tabela e chips de categoria viraram um único bloco sticky top-0, com fundo bg-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.
  • 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 de bg-background piano. O item ativo do toggle grid/tabela usa variant="default" (bg-primary--abc-primary-branding, navy #0b2650) em vez de variant="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). O toast.success('Post publicado') manual no BlogPublishDialog duplicava 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.DateTimeFormat com month: '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.