From 70b517c9c642d7f0311e4744ab3e078a2aa4cacc Mon Sep 17 00:00:00 2001 From: Gabriel Date: Tue, 25 Aug 2026 11:55:29 -0300 Subject: [PATCH] =?UTF-8?q?Revis=C3=A3o=20das=20notifica=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 6 ++- plano.md | 9 ++++ static/js/notifications.js | 94 +++++++++++++++++++++++++++++++++++--- 3 files changed, 101 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cabd2c8..88247bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,7 +214,11 @@ Padrão de guarda por página: `profiles.js`, `users-admin.js` e `widgets.js` ve `notifications.js` usa uma lista mockada em memória (`PID_NEW_TOOLS_NOTIFICATIONS`) para os anúncios de "nova ferramenta" — os itens de compromisso vêm de `/api/compromissos/` de verdade. O conteúdo das notificações continua mockado/derivado a cada carregamento, mas **quais delas o usuário já dispensou** (X individual ou "Limpar tudo") persiste por usuário via `NotificacaoDispensada`/`/api/notificacoes-dispensadas/` — por isso um item dispensado não reaparece depois de um reload. -**Histórico de notificações** (botão "Histórico" ao lado de "Limpar tudo", em `#notif-history-modal` — presente nos 7 shells que têm o sino, tudo exceto `index.html`): não é um model novo, é uma segunda leitura da mesma tabela `NotificacaoDispensada` — o sino ativo mostra `!dismissedIds.includes(id)`, o histórico mostra o inverso (`dismissedIds.includes(id)`). A diferença entre os dois pools de candidatos usados (`notifications.js`) é proposital: o sino usa `pidEventosElegiveisAgora()` (só eventos com `data >= hoje` e `notificar_em` já atingido, capado em 5 pra não lotar o dropdown), enquanto o histórico usa `allEventNotifications` (todos os compromissos que o usuário pode ver, sem nenhuma das duas restrições) — um item dispensado pode não estar mais no recorte "elegível agora" (compromisso já passou, ou o lembrete não bateu ainda depois de uma edição), mas ainda precisa aparecer no histórico. Cada item do histórico tem um botão "Restaurar" (`DELETE /api/notificacoes-dispensadas/{notif_id}/`, já existia como endpoint, só não tinha consumidor no frontend) que remove o registro de dispensa; a notificação só volta a aparecer no sino de fato se ainda estiver no pool "elegível agora" (ex.: restaurar uma notificação de ferramenta sempre reaparece no sino, já que esse pool é estático; restaurar um compromisso muito antigo não reaparece, porque ele não está mais entre os 5 mais próximos elegíveis — o histórico continua mostrando, já que sua lista não tem esse recorte). +**Notificação de ferramenta é gateada por permissão** (`n.access`, cada entrada de `PID_NEW_TOOLS_NOTIFICATIONS`) — decisão explícita do usuário, pra nunca anunciar uma aplicação que o usuário não pode acessar. `pidNotifToolElegivel()` reproduz o mesmo critério que já esconde o item correspondente no menu (`pidApplyAccessVisibility` em `access.js`): `{type:"gerencia"}` espelha o gate de `gerencia_permissoes` (usado por "Perfis de Acesso"/"Usuários", que não têm chave em `permissoes_efetivas`), `{type:"module", module:"..."}` espelha `permissoes_efetivas[module].enabled` (usado por "Calendário Individual"/"Widgets", que vivem dentro de `calendario-individual`/`principal`). O filtro roda uma vez, antes de montar `toolNotifications` — vale tanto pro sino quanto pro histórico, então uma notificação sem permissão nunca aparece em lugar nenhum, nem mesmo depois de dispensada. Ao adicionar uma entrada nova em `PID_NEW_TOOLS_NOTIFICATIONS`, sempre preencher `access` com o módulo/gate real da aplicação anunciada (ou omitir só se for algo que todo usuário autenticado pode ver, sem exceção). + +**Notificação de ferramenta expira em 10 dias** (`PID_NOTIF_TOOL_EXPIRA_DIAS`, `pidNotifToolExpirada()`, comparando `dataIso` da entrada contra a data de hoje): passado esse prazo, `notifications.js` dispensa a notificação sozinho no próprio carregamento da página (`POST /api/notificacoes-dispensadas/`, mesma chamada de quando o usuário clica no X) — daí em diante ela segue as mesmas regras de qualquer notificação dispensada manualmente (some do sino, aparece no histórico, pode ser restaurada). `toolNotificationsAgora` (o recorte usado pro sino, tanto na carga inicial quanto depois de um "Restaurar") já exclui as expiradas por prazo — restaurar uma notificação de ferramenta com mais de 10 dias mantém o rastro no histórico mas não a traz de volta ao sino, mesmo espírito de "restaurar um compromisso antigo não garante reaparecer no sino" (ver abaixo). Ao adicionar uma entrada nova, usar `dataIso` no formato `"AAAA-MM-DD"` (não `date` pré-formatado como antes) — `date`/`pidFormatNotifDate()` derivam o `"DD/MM"` de exibição a partir dele, mesmo padrão já usado pelos eventos do Calendário Individual. + +**Histórico de notificações** (botão "Histórico" ao lado de "Limpar tudo", em `#notif-history-modal` — presente nos 7 shells que têm o sino, tudo exceto `index.html`): não é um model novo, é uma segunda leitura da mesma tabela `NotificacaoDispensada` — o sino ativo mostra `!dismissedIds.includes(id)`, o histórico mostra o inverso (`dismissedIds.includes(id)`). A diferença entre os dois pools de candidatos usados (`notifications.js`) é proposital: o sino usa `pidEventosElegiveisAgora()`/`toolNotificationsAgora` (só eventos com `data >= hoje` e `notificar_em` já atingido, capado em 5 pra não lotar o dropdown; só notificações de ferramenta dentro dos 10 dias de prazo), enquanto o histórico usa `allEventNotifications`/`toolNotifications` (todos os compromissos que o usuário pode ver e toda notificação de ferramenta que ele tem permissão de ver, sem o recorte de "agora") — um item dispensado pode não estar mais no recorte "elegível agora" (compromisso já passou, lembrete não bateu ainda depois de uma edição, ou notificação de ferramenta já passou dos 10 dias), mas ainda precisa aparecer no histórico. Cada item do histórico tem um botão "Restaurar" (`DELETE /api/notificacoes-dispensadas/{notif_id}/`, já existia como endpoint, só não tinha consumidor no frontend) que remove o registro de dispensa; a notificação só volta a aparecer no sino de fato se ainda estiver no pool "elegível agora" (restaurar um compromisso muito antigo, ou uma notificação de ferramenta com mais de 10 dias, não reaparece no sino — o histórico continua mostrando, já que sua lista não tem esse recorte). ## Modelo de permissões (Perfis de Acesso) diff --git a/plano.md b/plano.md index c154ed4..4446c02 100644 --- a/plano.md +++ b/plano.md @@ -1127,6 +1127,15 @@ Usuário forneceu o PDF real do cliente Weitnauer Brasil (empresa 792 na planilh - Mesma técnica de reconstrução de linha por posição (`extract_words()` agrupadas por `top`) já usada na Unimed Vitória, porque o nome de um beneficiário longo quebra pro relatório — só que aqui o corte é mais agressivo (o próprio relatório trunca a última letra da palavra, ex. "SILV" em vez de "SILVA"), sem prejuízo nenhum já que o casamento é por CPF, não por nome. - Validado rodando o parser e o `pipeline.processa_importacao` completo contra o arquivo real + a planilha padrão real: 15 beneficiários extraídos, R$ 437,40 no total (bate com "Total R$ 437,40" impresso no relatório); 13 casaram certo por CPF contra a planilha padrão de teste, os outros 2 (ausentes dessa planilha) foram corretamente para auditoria "CPF não encontrado" em vez de ignorados/silenciosos. +### 87. Notificações de "nova ferramenta": gate de permissão + expiração automática em 10 dias + +Usuário viu, pelo dropdown do sino, notificações de ferramenta antigas (algumas com mais de duas semanas) ainda listadas como novas, e pediu duas correções: (1) notificação de uma aplicação que o usuário não tem permissão de acessar nunca deve aparecer; (2) toda notificação de "nova ferramenta" deve valer por só 10 dias — depois disso, cair sozinha pra dispensadas, sem esperar o usuário clicar no X. + +- Cada entrada de `PID_NEW_TOOLS_NOTIFICATIONS` (`notifications.js`) ganhou um campo `access` (`{type:"gerencia"}` ou `{type:"module", module:"..."}`) reproduzindo o mesmo gate que já esconde o item correspondente no menu (`pidApplyAccessVisibility` em `access.js`) — `pidNotifToolElegivel()` filtra a lista antes de montar `toolNotifications`, então uma notificação sem permissão nunca aparece nem no sino nem no histórico. +- `date: "DD/MM"` (string pré-formatada, sem ano) virou `dataIso: "AAAA-MM-DD"` — `pidNotifToolExpirada()` compara contra a data de hoje (`PID_NOTIF_TOOL_EXPIRA_DIAS = 10`); passado o prazo, `notifications.js` chama `POST /api/notificacoes-dispensadas/` sozinho no carregamento da página (mesma chamada do X manual), então a notificação passa a seguir 100% as regras já existentes de dispensa/histórico/restauração — nenhum mecanismo novo no backend. +- `toolNotificationsAgora` (recorte "elegível agora", análogo a `pidEventosElegiveisAgora()` pros compromissos) exclui as expiradas por prazo tanto na carga inicial quanto depois de um "Restaurar" — restaurar uma notificação de ferramenta com mais de 10 dias mantém o rastro no histórico, mas não a devolve ao sino. +- Ver CLAUDE.md, seção "Armazenamento" → "Histórico de notificações" pro detalhamento completo. + ## Roadmap / próximos passos Nenhuma pendência explícita em aberto no momento, exceto a limitação conhecida diff --git a/static/js/notifications.js b/static/js/notifications.js index 3ae7b2a..bec4d5e 100644 --- a/static/js/notifications.js +++ b/static/js/notifications.js @@ -1,10 +1,65 @@ +/** `access` diz qual permissão o usuário precisa ter pra essa notificação de + * "nova ferramenta" aparecer (sino e histórico) — mesmo critério que já + * esconde o item correspondente no menu (ver `pidApplyAccessVisibility` em + * access.js): `{type:"gerencia"}` espelha o gate de `gerencia_permissoes` + * usado por Perfis de Acesso/Usuários, `{type:"module", module:"..."}` + * espelha `permissoes_efetivas[module].enabled`. */ const PID_NEW_TOOLS_NOTIFICATIONS = [ - { id: "tool-calendario-individual", date: "01/08", title: "CALENDÁRIO INDIVIDUAL", breadcrumb: "Menu > Calendário Individual" }, - { id: "tool-perfis-acesso", date: "03/08", title: "PERFIS DE ACESSO", breadcrumb: "Menu > Administração > Perfis de Acesso" }, - { id: "tool-usuarios", date: "03/08", title: "USUÁRIOS", breadcrumb: "Menu > Administração > Usuários" }, - { id: "tool-widgets", date: "10/08", title: "WIDGETS NA TELA INICIAL", breadcrumb: "Menu > Principal > Widgets" }, + { + id: "tool-calendario-individual", + dataIso: "2026-08-01", + title: "CALENDÁRIO INDIVIDUAL", + breadcrumb: "Menu > Calendário Individual", + access: { type: "module", module: "calendario-individual" }, + }, + { + id: "tool-perfis-acesso", + dataIso: "2026-08-03", + title: "PERFIS DE ACESSO", + breadcrumb: "Menu > Administração > Perfis de Acesso", + access: { type: "gerencia" }, + }, + { + id: "tool-usuarios", + dataIso: "2026-08-03", + title: "USUÁRIOS", + breadcrumb: "Menu > Administração > Usuários", + access: { type: "gerencia" }, + }, + { + id: "tool-widgets", + dataIso: "2026-08-10", + title: "WIDGETS NA TELA INICIAL", + breadcrumb: "Menu > Principal > Widgets", + access: { type: "module", module: "principal" }, + }, ]; +/** Notificação de "nova ferramenta" só conta como nova por 10 dias a partir + * de `dataIso` — depois disso ela é dispensada automaticamente (ver uso em + * DOMContentLoaded abaixo), passando a valer as mesmas regras de + * histórico/restauração de qualquer outra notificação dispensada. */ +const PID_NOTIF_TOOL_EXPIRA_DIAS = 10; + +function pidNotifToolElegivel(n, me) { + if (!n.access) return true; + if (n.access.type === "gerencia") return !!me.gerencia_permissoes; + if (n.access.type === "module") { + const perm = (me.permissoes_efetivas || {})[n.access.module]; + return !!(perm && perm.enabled); + } + return true; +} + +function pidNotifToolExpirada(dataIso) { + const [ano, mes, dia] = dataIso.split("-").map(Number); + const limite = new Date(ano, mes - 1, dia); + limite.setDate(limite.getDate() + PID_NOTIF_TOOL_EXPIRA_DIAS); + const hoje = new Date(); + hoje.setHours(0, 0, 0, 0); + return hoje > limite; +} + const PID_NOTIF_ICON_TOOL = ''; @@ -79,9 +134,34 @@ document.addEventListener("DOMContentLoaded", async () => { const events = me ? await pidFetchEvents() : []; const eventNotifications = pidEventosElegiveisAgora(events); const allEventNotifications = events.slice().sort(pidSortEventsByDate).map(pidMapEventToNotification); - const toolNotifications = PID_NEW_TOOLS_NOTIFICATIONS.map((n) => ({ ...n, type: "tool" })); + // Só entra na lista quem o usuário tem permissão de ver — uma notificação + // de ferramenta cujo módulo/aplicação ele não acessa nunca aparece, nem no + // sino nem no histórico. + const toolNotifications = me + ? PID_NEW_TOOLS_NOTIFICATIONS.filter((n) => pidNotifToolElegivel(n, me)).map((n) => ({ + ...n, + type: "tool", + date: pidFormatNotifDate(n.dataIso), + })) + : []; let dismissedIds = me ? await pidFetchDismissedNotifIds() : []; - let notifications = [...eventNotifications, ...toolNotifications].filter((n) => !dismissedIds.includes(n.id)); + + // Notificação de ferramenta com mais de 10 dias vira dispensada sozinha, + // sem esperar o usuário clicar no X — daqui em diante ela segue as mesmas + // regras de qualquer outra notificação dispensada (sai do sino, entra no + // histórico, pode ser restaurada). + const expiradasAgora = toolNotifications.filter((n) => !dismissedIds.includes(n.id) && pidNotifToolExpirada(n.dataIso)); + if (expiradasAgora.length) { + await Promise.all(expiradasAgora.map((n) => pidDismissNotification(n.id))); + dismissedIds.push(...expiradasAgora.map((n) => n.id)); + } + + // "Agora" exclui as já expiradas por prazo (mesmo espírito de + // `eventNotifications` acima, que também é um recorte mais restrito que a + // lista completa) — restaurar uma notificação de ferramenta velha pelo + // histórico não a traz de volta pro sino, só mantém o rastro dela lá. + const toolNotificationsAgora = toolNotifications.filter((n) => !pidNotifToolExpirada(n.dataIso)); + let notifications = [...eventNotifications, ...toolNotificationsAgora].filter((n) => !dismissedIds.includes(n.id)); function render() { list.innerHTML = notifications.length @@ -186,7 +266,7 @@ document.addEventListener("DOMContentLoaded", async () => { const id = restoreBtn.getAttribute("data-notif-restore"); await pidRestoreNotification(id); dismissedIds = dismissedIds.filter((dismissedId) => dismissedId !== id); - notifications = [...eventNotifications, ...toolNotifications].filter((n) => !dismissedIds.includes(n.id)); + notifications = [...eventNotifications, ...toolNotificationsAgora].filter((n) => !dismissedIds.includes(n.id)); renderHistory(); render(); });