Merge pull request #81 from fsolla/cursor/d2-push-sino-in-app-af30

D2 — Push + sino in-app
This commit is contained in:
Francisco Solla 2026-07-30 23:25:11 -03:00 committed by GitHub
commit b2b11c3dfe
39 changed files with 14714 additions and 179 deletions

View file

@ -22,6 +22,12 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000
# `global_privacy-policy` for /privacidade. MUST be set in the Vercel project env for production. Generate with e.g. `openssl rand -hex 32`.
REVALIDATE_SECRET=YOUR_REVALIDATE_SECRET_HERE
# Web Push (D2 — /campanha notifications). Generate VAPID keys with: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:campanha@jorgesolla.com.br
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
# Campaign password reset emails (Resend). Required in production for forgot-password.
RESEND_API_KEY=YOUR_RESEND_API_KEY_HERE
CAMPAIGN_EMAIL_FROM=campanha@jorgesolla.com.br

View file

@ -1,7 +1,7 @@
# Notificações (push PWA + sino in-app)
Status: rascunho
Atualizado em: 2026-07-17
Status: entregue (D2, 2026-07-31)
Atualizado em: 2026-07-31
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
Responsável: —

View file

@ -113,6 +113,7 @@
"tailwindcss-animate": "^1.0.7",
"topojson-client": "^3.1.0",
"tw-animate-css": "^1.4.0",
"web-push": "^3.6.7",
"zod": "^4.3.6"
},
"devDependencies": {
@ -131,6 +132,7 @@
"@types/topojson-server": "^3.0.4",
"@types/topojson-simplify": "^3.0.3",
"@types/topojson-specification": "^1.0.5",
"@types/web-push": "^3.6.4",
"@types/yauzl": "^3.4.0",
"@vercel/blob": "2.3.1",
"@vitejs/plugin-react": "4.5.2",

View file

@ -119,6 +119,9 @@ importers:
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
web-push:
specifier: ^3.6.7
version: 3.6.7
zod:
specifier: ^4.3.6
version: 4.3.6
@ -168,6 +171,9 @@ importers:
'@types/topojson-specification':
specifier: ^1.0.5
version: 1.0.5
'@types/web-push':
specifier: ^3.6.4
version: 3.6.4
'@types/yauzl':
specifier: ^3.4.0
version: 3.4.0
@ -1258,189 +1264,160 @@ packages:
resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.1.0':
resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.1.0':
resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.1.0':
resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.1.0':
resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.1.0':
resolution: {integrity: sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.1.0':
resolution: {integrity: sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.2':
resolution: {integrity: sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.2':
resolution: {integrity: sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.2':
resolution: {integrity: sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.2':
resolution: {integrity: sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.2':
resolution: {integrity: sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.2':
resolution: {integrity: sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.2':
resolution: {integrity: sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==}
@ -1681,28 +1658,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@15.4.8':
resolution: {integrity: sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@15.4.8':
resolution: {integrity: sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@15.4.8':
resolution: {integrity: sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@15.4.8':
resolution: {integrity: sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw==}
@ -1800,56 +1773,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-arm64-musl@0.137.0':
resolution: {integrity: sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-ppc64-gnu@0.137.0':
resolution: {integrity: sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-gnu@0.137.0':
resolution: {integrity: sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-musl@0.137.0':
resolution: {integrity: sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-s390x-gnu@0.137.0':
resolution: {integrity: sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-gnu@0.137.0':
resolution: {integrity: sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-musl@0.137.0':
resolution: {integrity: sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-openharmony-arm64@0.137.0':
resolution: {integrity: sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA==}
@ -1922,49 +1887,41 @@ packages:
resolution: {integrity: sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-arm64-musl@11.21.3':
resolution: {integrity: sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-linux-ppc64-gnu@11.21.3':
resolution: {integrity: sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-riscv64-gnu@11.21.3':
resolution: {integrity: sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-riscv64-musl@11.21.3':
resolution: {integrity: sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-linux-s390x-gnu@11.21.3':
resolution: {integrity: sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-x64-gnu@11.21.3':
resolution: {integrity: sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-x64-musl@11.21.3':
resolution: {integrity: sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-openharmony-arm64@11.21.3':
resolution: {integrity: sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg==}
@ -3026,28 +2983,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@ -3246,6 +3199,9 @@ packages:
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
'@types/web-push@3.6.4':
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
@ -3503,49 +3459,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@ -3793,6 +3741,9 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
asn1js@3.0.10:
resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
engines: {node: '>=12.0.0'}
@ -3863,6 +3814,9 @@ packages:
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
bn.js@4.12.5:
resolution: {integrity: sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==}
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
@ -3892,6 +3846,9 @@ packages:
bson-objectid@2.0.4:
resolution: {integrity: sha512-vgnKAUzcDoa+AeyYwXCoHyF2q6u/8H46dxu5JN+4/TZeq/Dlinn0K6GvxsCLb3LHUJl0m/TLiEK31kUwtgocMQ==}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@ -4456,6 +4413,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
eciesjs@0.4.18:
resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==}
engines: {bun: '>=1', deno: '>=2', node: '>=16'}
@ -5094,6 +5054,10 @@ packages:
resolution: {integrity: sha512-O5kPr7AW7wYd/BBiOezTwnVAnmSNFY+J7hlZD2X5IOxVBetjcHAiTXhzj0gMrnojQlwy+UT1/Y3H3vJ3UlmvLA==}
engines: {node: '>= 0.4.0'}
http_ece@1.2.0:
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
engines: {node: '>=16'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
@ -5486,6 +5450,12 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@ -5797,6 +5767,9 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
@ -7372,6 +7345,11 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
web-push@3.6.7:
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
engines: {node: '>= 16'}
hasBin: true
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
@ -10526,6 +10504,10 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {}
'@types/web-push@3.6.4':
dependencies:
'@types/node': 22.19.9
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1':
@ -11036,6 +11018,13 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
asn1.js@5.4.1:
dependencies:
bn.js: 4.12.5
inherits: 2.0.4
minimalistic-assert: 1.0.1
safer-buffer: 2.1.2
asn1js@3.0.10:
dependencies:
pvtsutils: 1.3.6
@ -11094,6 +11083,8 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
bn.js@4.12.5: {}
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
@ -11137,6 +11128,8 @@ snapshots:
bson-objectid@2.0.4: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
buffer@5.7.1:
@ -11582,6 +11575,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
eciesjs@0.4.18:
dependencies:
'@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0)
@ -12493,6 +12490,8 @@ snapshots:
http-status@2.1.0: {}
http_ece@1.2.0: {}
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@ -12836,6 +12835,17 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@ -13254,6 +13264,8 @@ snapshots:
mimic-function@5.0.1: {}
minimalistic-assert@1.0.1: {}
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4
@ -15139,6 +15151,16 @@ snapshots:
dependencies:
defaults: 1.0.4
web-push@3.6.7:
dependencies:
asn1.js: 5.4.1
http_ece: 1.2.0
https-proxy-agent: 7.0.6
jws: 4.0.1
minimist: 1.2.8
transitivePeerDependencies:
- supports-color
web-streams-polyfill@3.3.3: {}
webidl-conversions@8.0.1: {}

View file

@ -10,6 +10,7 @@ import {
type BiometricEnrollmentOffer,
} from '@/components/campaign/shell/BiometricEnrollmentToast'
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import { CampaignNotificationBellSlot } from '@/components/campaign/shell/CampaignNotificationBellSlot'
import { CampaignSidebar } from '@/components/campaign/shell/CampaignSidebar'
import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/CampaignSidebarViewportDefault'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
@ -73,9 +74,12 @@ export default async function CampaignAppLayout({ children }: { children: React.
<SidebarInset className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
<CampaignWizardChromeProvider>
<CampaignListPendingBoundary>
<CampaignMobileTopBar />
<CampaignMobileTopBar notificationBell={<CampaignNotificationBellSlot user={user} />} />
<header className="hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden">
<SidebarTrigger />
<div className="ml-auto">
<CampaignNotificationBellSlot user={user} />
</div>
</header>
<div
data-slot="campaign-content-scroll"

View file

@ -5,9 +5,12 @@ import { getPayload } from 'payload'
import { CampaignPasskeysCard } from '@/components/campaign/auth/CampaignPasskeysCard'
import { CampaignProfileSettings } from '@/components/campaign/auth/CampaignProfileSettings'
import { CampaignPushNotificationsCard } from '@/components/campaign/auth/CampaignPushNotificationsCard'
import { deviceLabelFromUserAgent } from '@/lib/deviceLabel'
import { getCampaignPushConsent } from '@/utilities/campaignConsent'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { campaignUserShellView } from '@/utilities/campaignUserProfile'
import { getCampaignVapidPublicKey } from '@/utilities/notification/sendCampaignPush'
import { loadCampaignPasskeys } from '@/utilities/webauthn/campaignWebAuthnCeremony'
import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/webauthn/campaignWebAuthnConfig'
@ -27,29 +30,36 @@ export default async function CampaignProfilePage({ searchParams }: CampaignProf
const [user, params] = await Promise.all([requireCampaignPageActor(), searchParams])
const payload = await getPayload({ config })
const [passkeys, relyingParty, requestHeaders] = await Promise.all([
const [passkeys, relyingParty, requestHeaders, pushConsent] = await Promise.all([
loadCampaignPasskeys(payload, user.id),
resolveCampaignWebAuthnRelyingParty(),
headers(),
getCampaignPushConsent(payload),
])
return (
<CampaignProfileSettings
user={{
...campaignUserShellView(user),
email: user.email,
username: user.username,
}}
passwordResetBanner={params.passwordReset === '1'}
// Passed as a slot so the settings component stays a client island for
// its forms while this card owns its own state next to it.
biometricsSlot={
<CampaignPasskeysCard
passkeys={passkeys}
biometricsConfigured={relyingParty !== null}
suggestedDeviceLabel={deviceLabelFromUserAgent(requestHeaders.get('user-agent'))}
/>
}
/>
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
<CampaignProfileSettings
user={{
...campaignUserShellView(user),
email: user.email,
username: user.username,
}}
passwordResetBanner={params.passwordReset === '1'}
// Passed as a slot so the settings component stays a client island for
// its forms while this card owns its own state next to it.
biometricsSlot={
<CampaignPasskeysCard
passkeys={passkeys}
biometricsConfigured={relyingParty !== null}
suggestedDeviceLabel={deviceLabelFromUserAgent(requestHeaders.get('user-agent'))}
/>
}
/>
<CampaignPushNotificationsCard
pushConsentConfigured={pushConsent !== null}
vapidPublicKey={getCampaignVapidPublicKey()}
/>
</div>
)
}

View file

@ -0,0 +1,183 @@
'use server'
/** Server actions verify ownership then write with admin bypass (`overrideAccess: true`). */
import config from '@payload-config'
import { revalidatePath } from 'next/cache'
import { headers } from 'next/headers'
import { getPayload } from 'payload'
import {
CAMPAIGN_NOTIFICATION_MARK_ALL_READ_ERROR_MESSAGE,
CAMPAIGN_NOTIFICATION_THROW_SAFE_MESSAGES,
CAMPAIGN_PUSH_CONSENT_REQUIRED_MESSAGE,
CAMPAIGN_PUSH_SUBSCRIBE_ERROR_MESSAGE,
CAMPAIGN_PUSH_SUBSCRIPTION_INVALID_MESSAGE,
CAMPAIGN_PUSH_UNSUBSCRIBE_ERROR_MESSAGE,
} from '@/lib/campaignNotificationCopy'
import type { NotificationListItem } from '@/lib/notificationContract'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { requireCampaignPushConsent } from '@/utilities/campaignConsent'
import {
CAMPAIGN_AUTH_REQUIRED_MESSAGE,
runCampaignFormAction,
} from '@/utilities/campaignFormActionError'
import {
countUnreadNotifications,
loadNotificationList,
} from '@/utilities/notification/notificationList'
import { getCampaignVapidPublicKey } from '@/utilities/notification/sendCampaignPush'
const CAMPAIGN_NOTIFICATION_SAFE_MESSAGES = [
CAMPAIGN_AUTH_REQUIRED_MESSAGE,
...CAMPAIGN_NOTIFICATION_THROW_SAFE_MESSAGES,
] as const
export type NotificationBellData = {
unreadCount: number
vapidPublicKey: string | null
}
export const loadNotificationBellData = async (): Promise<NotificationBellData | null> => {
const user = await getCampaignUser()
if (!user) return null
const payload = await getPayload({ config })
const unreadCount = await countUnreadNotifications(payload, user.id)
return {
unreadCount,
vapidPublicKey: getCampaignVapidPublicKey(),
}
}
export const listCampaignNotifications = async (): Promise<NotificationListItem[]> => {
const user = await getCampaignUser()
if (!user) return []
const payload = await getPayload({ config })
return loadNotificationList(payload, user.id)
}
export const markAllCampaignNotificationsRead = async () =>
runCampaignFormAction({
execute: async () => {
const user = await getCampaignUser()
if (!user) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE)
const payload = await getPayload({ config })
const unread = await payload.find({
collection: 'notification',
where: {
and: [{ recipient: { equals: user.id } }, { readAt: { exists: false } }],
},
depth: 0,
limit: 200,
pagination: false,
overrideAccess: true,
})
const readAt = new Date().toISOString()
await Promise.all(
unread.docs.map((doc) =>
payload.update({
collection: 'notification',
id: doc.id,
data: { readAt },
depth: 0,
overrideAccess: true,
}),
),
)
revalidatePath('/campanha', 'layout')
return { message: 'Todas as notificações foram marcadas como lidas.' }
},
safeMessages: CAMPAIGN_NOTIFICATION_SAFE_MESSAGES,
genericMessage: CAMPAIGN_NOTIFICATION_MARK_ALL_READ_ERROR_MESSAGE,
})
type PushSubscribeInput = {
endpoint: string
p256dh: string
auth: string
expirationTime?: number | null
consentAccepted: boolean
}
export const subscribeCampaignPush = async (input: PushSubscribeInput) =>
runCampaignFormAction({
execute: async () => {
const user = await getCampaignUser()
if (!user) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE)
if (!input.consentAccepted) {
throw new Error(CAMPAIGN_PUSH_CONSENT_REQUIRED_MESSAGE)
}
const payload = await getPayload({ config })
const consent = await requireCampaignPushConsent(payload)
const requestHeaders = await headers()
const endpoint = input.endpoint.trim()
const p256dh = input.p256dh.trim()
const auth = input.auth.trim()
if (!endpoint || !p256dh || !auth) {
throw new Error(CAMPAIGN_PUSH_SUBSCRIPTION_INVALID_MESSAGE)
}
await payload.create({
collection: 'pushSubscription',
data: {
user: user.id,
endpoint,
p256dh,
auth,
expirationTime: input.expirationTime ?? undefined,
consent: consent.id,
consentContentHash: consent.contentHash,
consentedAt: new Date().toISOString(),
userAgent: requestHeaders.get('user-agent') ?? undefined,
},
depth: 0,
overrideAccess: true,
})
return { message: 'Avisos push ativados neste dispositivo.' }
},
safeMessages: CAMPAIGN_NOTIFICATION_SAFE_MESSAGES,
genericMessage: CAMPAIGN_PUSH_SUBSCRIBE_ERROR_MESSAGE,
})
export const unsubscribeCampaignPush = async (endpoint: string) =>
runCampaignFormAction({
execute: async () => {
const user = await getCampaignUser()
if (!user) throw new Error(CAMPAIGN_AUTH_REQUIRED_MESSAGE)
const payload = await getPayload({ config })
const existing = await payload.find({
collection: 'pushSubscription',
where: {
and: [{ user: { equals: user.id } }, { endpoint: { equals: endpoint } }],
},
depth: 0,
limit: 1,
pagination: false,
user,
overrideAccess: false,
})
const subscription = existing.docs[0]
if (subscription) {
await payload.delete({
collection: 'pushSubscription',
id: subscription.id,
overrideAccess: true,
})
}
return { message: 'Avisos push desativados neste dispositivo.' }
},
safeMessages: CAMPAIGN_NOTIFICATION_SAFE_MESSAGES,
genericMessage: CAMPAIGN_PUSH_UNSUBSCRIBE_ERROR_MESSAGE,
})

View file

@ -284,6 +284,20 @@ export const Activity: CollectionConfig = {
hooks: {
beforeValidate: [setCanonicalActivitySlug, validateActivitySchedule, validateActivityAdvisors],
beforeChange: [deriveActivityFields],
afterChange: [
async ({ doc, operation, previousDoc, req }) => {
const becameVisible =
operation === 'create'
? doc.status !== 'rascunho'
: previousDoc?.status === 'rascunho' && doc.status !== 'rascunho'
if (!becameVisible) return doc
const { notifyActivityNeedsAttention } =
await import('@/utilities/notification/notificationEvents')
await notifyActivityNeedsAttention(req, doc)
return doc
},
],
},
fields: [
{

View file

@ -116,6 +116,21 @@ const deleteCampaignUserPasskeys: CollectionBeforeDeleteHook = async ({ id, req
})
}
const deleteCampaignUserNotifications: CollectionBeforeDeleteHook = async ({ id, req }) => {
await req.payload.delete({
collection: 'notification',
where: { recipient: { equals: id } },
overrideAccess: true,
req,
})
await req.payload.delete({
collection: 'pushSubscription',
where: { user: { equals: id } },
overrideAccess: true,
req,
})
}
export const CampaignUser: CollectionConfig = {
slug: 'campaignUser',
labels: {
@ -169,7 +184,7 @@ export const CampaignUser: CollectionConfig = {
},
hooks: {
beforeChange: [preventAssignedAdvisorDowngrade, preventSelfServicePrivilegedFields],
beforeDelete: [deleteCampaignUserPasskeys],
beforeDelete: [deleteCampaignUserPasskeys, deleteCampaignUserNotifications],
afterRead: [removePrivateAuthFields],
},
fields: [

View file

@ -150,6 +150,7 @@ const lockMunicipalityBeforeDelete: CollectionBeforeDeleteHook = async ({ contex
const recomputeChangedMunicipalities: CollectionAfterChangeHook = async ({
context,
doc,
operation,
previousDoc,
req,
}) => {
@ -163,6 +164,13 @@ const recomputeChangedMunicipalities: CollectionAfterChangeHook = async ({
await acquireMunicipalityUpdateLocks(req, municipalityIDs)
await recomputeMunicipalityLastUpdateAt(req, municipalityIDs)
if (operation === 'create') {
const { notifyMunicipalityUpdateCreated } =
await import('@/utilities/notification/notificationEvents')
await notifyMunicipalityUpdateCreated(req, doc)
}
return doc
}

View file

@ -0,0 +1,78 @@
import type { CollectionConfig } from 'payload'
import { notificationTypeLabels, notificationTypes } from '@/lib/notificationContract'
import {
canDeleteOwnNotifications,
canReadOwnNotifications,
canWriteNotifications,
} from '@/utilities/campaignAccess'
const NOTIFICATION_TYPE_OPTIONS = notificationTypes.map((value) => ({
label: notificationTypeLabels[value],
value,
}))
export const Notification: CollectionConfig = {
slug: 'notification',
labels: {
singular: 'Notificação',
plural: 'Notificações',
},
admin: {
group: 'Campanha',
useAsTitle: 'type',
defaultColumns: ['recipient', 'type', 'readAt', 'createdAt'],
description: 'Avisos in-app e push da campanha. Cada linha pertence a um destinatário.',
},
access: {
create: canWriteNotifications,
read: canReadOwnNotifications,
update: canWriteNotifications,
delete: canDeleteOwnNotifications,
},
fields: [
{
name: 'recipient',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Destinatário',
required: true,
index: true,
},
{
name: 'type',
type: 'select',
label: 'Tipo',
required: true,
index: true,
options: NOTIFICATION_TYPE_OPTIONS,
},
{
name: 'payload',
type: 'json',
label: 'Conteúdo',
required: true,
admin: {
description: 'Título, detalhe e href exibidos no sino e no push.',
},
},
{
name: 'municipality',
type: 'relationship',
relationTo: 'municipality',
label: 'Município',
index: true,
},
{
name: 'readAt',
type: 'date',
label: 'Lida em',
index: true,
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
],
}

View file

@ -0,0 +1,94 @@
import type { CollectionConfig } from 'payload'
import {
canDeleteOwnPushSubscriptions,
canReadOwnPushSubscriptions,
canWritePushSubscriptions,
} from '@/utilities/campaignAccess'
export const PushSubscription: CollectionConfig = {
slug: 'pushSubscription',
labels: {
singular: 'Inscrição push',
plural: 'Inscrições push',
},
admin: {
group: 'Campanha',
useAsTitle: 'endpoint',
defaultColumns: ['user', 'endpoint', 'consentedAt', 'createdAt'],
description: 'Endpoints Web Push por dispositivo. Não armazena dados biométricos.',
},
access: {
create: canWritePushSubscriptions,
read: canReadOwnPushSubscriptions,
update: canWritePushSubscriptions,
delete: canDeleteOwnPushSubscriptions,
},
fields: [
{
name: 'user',
type: 'relationship',
relationTo: 'campaignUser',
label: 'Usuário',
required: true,
index: true,
},
{
name: 'endpoint',
type: 'text',
label: 'Endpoint',
required: true,
unique: true,
index: true,
admin: { readOnly: true },
},
{
name: 'p256dh',
type: 'text',
label: 'Chave p256dh',
required: true,
admin: { readOnly: true },
},
{
name: 'auth',
type: 'text',
label: 'Chave auth',
required: true,
admin: { readOnly: true },
},
{
name: 'expirationTime',
type: 'number',
label: 'Expiração (ms)',
admin: { readOnly: true },
},
{
name: 'consent',
type: 'relationship',
relationTo: 'consent',
label: 'Consentimento',
required: true,
admin: { readOnly: true },
},
{
name: 'consentContentHash',
type: 'text',
label: 'Hash do conteúdo consentido',
required: true,
admin: { readOnly: true },
},
{
name: 'consentedAt',
type: 'date',
label: 'Consentido em',
required: true,
admin: { readOnly: true },
},
{
name: 'userAgent',
type: 'text',
label: 'User agent',
admin: { readOnly: true },
},
],
}

View file

@ -63,6 +63,15 @@ export const Supporter: CollectionConfig = {
return data
},
],
afterChange: [
async ({ doc, operation, req }) => {
if (operation !== 'create') return doc
const { notifySupporterCreated } =
await import('@/utilities/notification/notificationEvents')
await notifySupporterCreated(req, doc)
return doc
},
],
},
fields: [
{

View file

@ -0,0 +1,212 @@
'use client'
import { useState, useTransition } from 'react'
import {
subscribeCampaignPush,
unsubscribeCampaignPush,
} from '@/app/(campaign)/campanha/actions/notifications'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
import { Spinner } from '@/components/ui/Spinner'
import { CAMPAIGN_PWA_SW_PATH } from '@/utilities/campaignPwa'
type CampaignPushNotificationsCardProps = {
pushConsentConfigured: boolean
vapidPublicKey: string | null
}
const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = window.atob(base64)
const output = new Uint8Array(raw.length)
for (let index = 0; index < raw.length; index += 1) {
output[index] = raw.charCodeAt(index)
}
return output
}
const isIosSafari = (): boolean => {
const ua = navigator.userAgent
const isIos =
/iPad|iPhone|iPod/.test(ua) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
return isIos && /WebKit/.test(ua) && !/CriOS|FxiOS|EdgiOS/.test(ua)
}
const isStandaloneDisplay = (): boolean => {
const standaloneMedia = window.matchMedia('(display-mode: standalone)').matches
const iosStandalone = Boolean((navigator as Navigator & { standalone?: boolean }).standalone)
return standaloneMedia || iosStandalone
}
export const CampaignPushNotificationsCard = ({
pushConsentConfigured,
vapidPublicKey,
}: CampaignPushNotificationsCardProps) => {
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [consentAccepted, setConsentAccepted] = useState(false)
const [isPending, startTransition] = useTransition()
const handleEnable = () => {
startTransition(async () => {
setMessage(null)
setError(null)
if (!pushConsentConfigured) {
setError('Consentimento de push ainda não configurado no admin.')
return
}
if (!vapidPublicKey) {
setError('Push ainda não está disponível neste ambiente.')
return
}
if (!consentAccepted) {
setError('Aceite o consentimento para ativar os avisos push.')
return
}
if (isIosSafari() && !isStandaloneDisplay()) {
setError('No iPhone, instale o app na tela inicial antes de ativar os avisos push.')
return
}
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
setError('Este navegador não suporta notificações push.')
return
}
try {
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
setError('Permissão de notificação negada.')
return
}
const registration = await navigator.serviceWorker.register(CAMPAIGN_PWA_SW_PATH, {
scope: '/campanha',
})
await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
})
const json = subscription.toJSON()
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
setError('Não foi possível registrar este dispositivo para push.')
return
}
const result = await subscribeCampaignPush({
endpoint: json.endpoint,
p256dh: json.keys.p256dh,
auth: json.keys.auth,
expirationTime: subscription.expirationTime,
consentAccepted: true,
})
if ('status' in result && result.status === 'success') {
setMessage(result.message)
} else if ('message' in result && result.message) {
setError(result.message)
} else {
setError('Não foi possível ativar os avisos push.')
}
} catch {
setError('Não foi possível ativar os avisos push neste dispositivo.')
}
})
}
const handleDisable = () => {
startTransition(async () => {
setMessage(null)
setError(null)
try {
const registration = await navigator.serviceWorker.getRegistration(CAMPAIGN_PWA_SW_PATH)
const subscription = await registration?.pushManager.getSubscription()
if (!subscription) {
setMessage('Nenhuma inscrição push ativa neste dispositivo.')
return
}
const endpoint = subscription.endpoint
await subscription.unsubscribe()
const result = await unsubscribeCampaignPush(endpoint)
if ('status' in result && result.status === 'success') {
setMessage(result.message)
} else if ('message' in result && result.message) {
setError(result.message)
} else {
setError('Não foi possível desativar os avisos push.')
}
} catch {
setError('Não foi possível desativar os avisos push.')
}
})
}
return (
<Card>
<CardHeader>
<CardTitle>Avisos push</CardTitle>
<CardDescription>
Receba alertas da campanha mesmo com o app fechado. No iPhone, só funciona com o app
instalado na tela inicial.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{!pushConsentConfigured ? (
<Alert variant="destructive">
<AlertTitle>Consentimento não configurado</AlertTitle>
<AlertDescription>
O texto LGPD ainda não foi cadastrado no admin. A ativação de push fica bloqueada até
a assessoria publicar a chave <code>campanha-notificacoes-push</code>.
</AlertDescription>
</Alert>
) : (
<Field orientation="horizontal">
<Checkbox
id="push-consent"
checked={consentAccepted}
onCheckedChange={(checked: boolean | 'indeterminate') =>
setConsentAccepted(checked === true)
}
disabled={isPending}
/>
<FieldContent>
<FieldLabel htmlFor="push-consent">Aceito receber avisos push da campanha</FieldLabel>
<FieldDescription>
Você pode desativar a qualquer momento nesta tela ou nas configurações do navegador.
</FieldDescription>
</FieldContent>
</Field>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={handleEnable} disabled={isPending || !vapidPublicKey}>
{isPending ? <Spinner className="size-4" /> : null}
Ativar neste dispositivo
</Button>
<Button type="button" variant="outline" onClick={handleDisable} disabled={isPending}>
Desativar neste dispositivo
</Button>
</div>
{message ? (
<p className="text-sm text-foreground" role="status">
{message}
</p>
) : null}
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
</CardContent>
</Card>
)
}

View file

@ -1,6 +1,7 @@
'use client'
import { ArrowLeft, X } from 'lucide-react'
import type { ReactNode } from 'react'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
@ -27,7 +28,7 @@ const wizardSkipButtonClass =
const wizardTitleSkeletonClass = 'mx-auto motion-reduce:animate-none bg-primary-foreground/20'
export const CampaignMobileTopBar = () => {
export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?: ReactNode }) => {
const chrome = useCampaignWizardChrome()
const { isPending } = useCampaignListTransition()
@ -129,10 +130,11 @@ export const CampaignMobileTopBar = () => {
)}
>
<SidebarTrigger className="text-primary-foreground" />
<div className="min-w-0 leading-tight">
<div className="min-w-0 flex-1 leading-tight">
<span className="block truncate text-sm font-semibold">Jorge Solla</span>
<span className="block truncate text-xs text-primary-foreground/80">Campanha · Bahia</span>
</div>
{notificationBell ? <div className="shrink-0">{notificationBell}</div> : null}
</header>
)
}

View file

@ -0,0 +1,200 @@
'use client'
import {
Activity,
Bell,
ClipboardList,
UserPlus,
UserRoundCheck,
type LucideIcon,
} from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState, useTransition } from 'react'
import {
listCampaignNotifications,
markAllCampaignNotificationsRead,
type NotificationBellData,
} from '@/app/(campaign)/campanha/actions/notifications'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import type { NotificationListItem } from '@/lib/notificationContract'
import { notificationTypeLabels, type NotificationType } from '@/lib/notificationContract'
import { cn } from '@/lib/utils'
import { formatRelativeAge } from '@/utilities/formatRelativeAge'
const notificationIcons: Record<NotificationType, LucideIcon> = {
municipality_update: ClipboardList,
new_supporter: UserPlus,
activity_attention: Activity,
invite_accepted: UserRoundCheck,
}
type CampaignNotificationBellProps = NotificationBellData
export const CampaignNotificationBell = ({
unreadCount: initialUnreadCount,
vapidPublicKey: _vapidPublicKey,
}: CampaignNotificationBellProps) => {
const router = useRouter()
const [open, setOpen] = useState(false)
const [unreadCount, setUnreadCount] = useState(initialUnreadCount)
const [items, setItems] = useState<NotificationListItem[]>([])
const [isPending, startTransition] = useTransition()
const [loadError, setLoadError] = useState<string | null>(null)
const nowMs = Date.now()
useEffect(() => {
setUnreadCount(initialUnreadCount)
}, [initialUnreadCount])
const loadItems = useCallback(() => {
startTransition(async () => {
setLoadError(null)
try {
const nextItems = await listCampaignNotifications()
setItems(nextItems)
setUnreadCount(nextItems.filter((item) => !item.readAt).length)
} catch {
setLoadError('Não foi possível carregar as notificações.')
}
})
}, [])
useEffect(() => {
if (!open) return
loadItems()
}, [open, loadItems])
const handleMarkAllRead = () => {
startTransition(async () => {
const result = await markAllCampaignNotificationsRead()
if ('status' in result && result.status === 'success') {
setItems((current) =>
current.map((item) => ({
...item,
readAt: item.readAt ?? new Date().toISOString(),
})),
)
setUnreadCount(0)
router.refresh()
}
})
}
const unreadLabel =
unreadCount === 0
? 'Notificações'
: unreadCount === 1
? '1 notificação não lida'
: `${unreadCount} notificações não lidas`
return (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="relative size-11 shrink-0 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground md:text-foreground md:hover:bg-muted"
aria-label={unreadLabel}
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen(true)}
>
<Bell className="size-5" aria-hidden />
{unreadCount > 0 ? (
<span className="absolute right-1.5 top-1.5 flex min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-4 text-destructive-foreground">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
) : null}
</Button>
<Drawer open={open} onOpenChange={setOpen} showSwipeHandle>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader className="text-left">
<DrawerTitle>Notificações</DrawerTitle>
<DrawerDescription>
{unreadCount > 0
? `${unreadCount} não ${unreadCount === 1 ? 'lida' : 'lidas'}`
: 'Tudo em dia'}
</DrawerDescription>
</DrawerHeader>
<div
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4 pb-2"
aria-busy={isPending}
>
{loadError ? <p className="text-sm text-destructive">{loadError}</p> : null}
{!loadError && items.length === 0 && !isPending ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Nenhuma notificação por aqui.
</p>
) : null}
{items.map((item) => {
const Icon = notificationIcons[item.type]
const isUnread = !item.readAt
return (
<Link
key={item.id}
href={item.payload.href}
onClick={() => setOpen(false)}
className={cn(
'flex gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/50',
isUnread && 'border-primary/30 bg-primary/5',
)}
>
<span className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted">
<Icon className="size-4 text-muted-foreground" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium leading-snug">
{item.payload.title}
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{item.payload.detail}
</span>
<span className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{notificationTypeLabels[item.type]}</span>
<span aria-hidden>·</span>
<time dateTime={item.createdAt}>
{formatRelativeAge(new Date(item.createdAt).getTime(), nowMs)}
</time>
{isUnread ? (
<span className="ml-auto size-2 rounded-full bg-primary" aria-hidden />
) : null}
</span>
</span>
</Link>
)
})}
</div>
<DrawerFooter className="border-t">
<Button
type="button"
variant="outline"
className="w-full"
disabled={isPending || unreadCount === 0}
onClick={handleMarkAllRead}
>
Marcar todas como lidas
</Button>
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
)
}

View file

@ -0,0 +1,17 @@
import { loadNotificationBellData } from '@/app/(campaign)/campanha/actions/notifications'
import { CampaignNotificationBell } from '@/components/campaign/shell/CampaignNotificationBell'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import type { CampaignUser } from '@/payload-types'
type CampaignNotificationBellSlotProps = {
user: CampaignUser
}
export const CampaignNotificationBellSlot = async ({ user }: CampaignNotificationBellSlotProps) => {
if (!isStaffCampaignRole(user.role)) return null
const bellData = await loadNotificationBellData()
if (!bellData) return null
return <CampaignNotificationBell {...bellData} />
}

View file

@ -24,3 +24,7 @@ export const SUPPORTER_VOTE_INTENTION_CONSENT_MISSING_MESSAGE =
* tags that live document with this stable key (Pass 2 D3).
*/
export const WHATSAPP_SUBSCRIPTION_CONSENT_KEY = 'whatsapp-inscricao'
export const CAMPAIGN_PUSH_CONSENT_KEY = 'campanha-notificacoes-push'
export const CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE =
'Consentimento de notificações push ainda não configurado.'

View file

@ -0,0 +1,19 @@
import { CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE } from '@/lib/campaignConsentKeys'
export const CAMPAIGN_PUSH_CONSENT_REQUIRED_MESSAGE =
'É necessário aceitar o consentimento para receber avisos push.'
export const CAMPAIGN_PUSH_SUBSCRIPTION_INVALID_MESSAGE = 'Dados de inscrição push inválidos.'
export const CAMPAIGN_NOTIFICATION_MARK_ALL_READ_ERROR_MESSAGE =
'Não foi possível marcar as notificações como lidas.'
export const CAMPAIGN_PUSH_SUBSCRIBE_ERROR_MESSAGE = 'Não foi possível ativar os avisos push.'
export const CAMPAIGN_PUSH_UNSUBSCRIBE_ERROR_MESSAGE = 'Não foi possível desativar os avisos push.'
export const CAMPAIGN_NOTIFICATION_THROW_SAFE_MESSAGES = [
CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE,
CAMPAIGN_PUSH_CONSENT_REQUIRED_MESSAGE,
CAMPAIGN_PUSH_SUBSCRIPTION_INVALID_MESSAGE,
] as const

View file

@ -0,0 +1,35 @@
/** Payload `req.context` flag — domain hooks skip creating notifications when true. */
export const SKIP_CAMPAIGN_NOTIFICATION_CONTEXT_KEY = 'skipCampaignNotification'
/** Opt back into hook-fired notifications inside Vitest (default: skipped). */
export const ENABLE_CAMPAIGN_NOTIFICATION_CONTEXT_KEY = 'enableCampaignNotification'
export const notificationTypes = [
'municipality_update',
'new_supporter',
'activity_attention',
'invite_accepted',
] as const
export type NotificationType = (typeof notificationTypes)[number]
export type NotificationPayload = {
title: string
detail: string
href: string
}
export type NotificationListItem = {
id: number
type: NotificationType
payload: NotificationPayload
readAt: string | null
createdAt: string
}
export const notificationTypeLabels: Record<NotificationType, string> = {
municipality_update: 'Reporte de campo',
new_supporter: 'Novo apoiador',
activity_attention: 'Atividade',
invite_accepted: 'Convite aceito',
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_notification_type" AS ENUM('municipality_update', 'new_supporter', 'activity_attention', 'invite_accepted');
CREATE TABLE "notification" (
"id" serial PRIMARY KEY NOT NULL,
"recipient_id" integer NOT NULL,
"type" "enum_notification_type" NOT NULL,
"payload" jsonb NOT NULL,
"municipality_id" integer,
"read_at" timestamp(3) with time zone,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "push_subscription" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"endpoint" varchar NOT NULL,
"p256dh" varchar NOT NULL,
"auth" varchar NOT NULL,
"expiration_time" numeric,
"consent_id" integer NOT NULL,
"consent_content_hash" varchar NOT NULL,
"consented_at" timestamp(3) with time zone NOT NULL,
"user_agent" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "notification_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "push_subscription_id" integer;
ALTER TABLE "notification" ADD CONSTRAINT "notification_recipient_id_campaign_user_id_fk" FOREIGN KEY ("recipient_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "notification" ADD CONSTRAINT "notification_municipality_id_municipality_id_fk" FOREIGN KEY ("municipality_id") REFERENCES "public"."municipality"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "push_subscription" ADD CONSTRAINT "push_subscription_user_id_campaign_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."campaign_user"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "push_subscription" ADD CONSTRAINT "push_subscription_consent_id_consent_id_fk" FOREIGN KEY ("consent_id") REFERENCES "public"."consent"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "notification_recipient_idx" ON "notification" USING btree ("recipient_id");
CREATE INDEX "notification_type_idx" ON "notification" USING btree ("type");
CREATE INDEX "notification_municipality_idx" ON "notification" USING btree ("municipality_id");
CREATE INDEX "notification_read_at_idx" ON "notification" USING btree ("read_at");
CREATE INDEX "notification_updated_at_idx" ON "notification" USING btree ("updated_at");
CREATE INDEX "notification_created_at_idx" ON "notification" USING btree ("created_at");
CREATE INDEX "push_subscription_user_idx" ON "push_subscription" USING btree ("user_id");
CREATE UNIQUE INDEX "push_subscription_endpoint_idx" ON "push_subscription" USING btree ("endpoint");
CREATE INDEX "push_subscription_consent_idx" ON "push_subscription" USING btree ("consent_id");
CREATE INDEX "push_subscription_updated_at_idx" ON "push_subscription" USING btree ("updated_at");
CREATE INDEX "push_subscription_created_at_idx" ON "push_subscription" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_notification_fk" FOREIGN KEY ("notification_id") REFERENCES "public"."notification"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_push_subscription_fk" FOREIGN KEY ("push_subscription_id") REFERENCES "public"."push_subscription"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_notification_id_idx" ON "payload_locked_documents_rels" USING btree ("notification_id");
CREATE INDEX "payload_locked_documents_rels_push_subscription_id_idx" ON "payload_locked_documents_rels" USING btree ("push_subscription_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "notification" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "push_subscription" DISABLE ROW LEVEL SECURITY;
DROP TABLE "notification" CASCADE;
DROP TABLE "push_subscription" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_notification_fk";
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_push_subscription_fk";
DROP INDEX "payload_locked_documents_rels_notification_id_idx";
DROP INDEX "payload_locked_documents_rels_push_subscription_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "notification_id";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "push_subscription_id";
DROP TYPE "public"."enum_notification_type";`)
}

View file

@ -36,6 +36,7 @@ import * as migration_20260728_041547_add_allocation_decision_adiada_outcome fro
import * as migration_20260728_041958_add_campaign_webauthn_credentials from './20260728_041958_add_campaign_webauthn_credentials';
import * as migration_20260730_010601_simplify_municipality_signal_fields from './20260730_010601_simplify_municipality_signal_fields';
import * as migration_20260730_043306_simplify_leadership_fields from './20260730_043306_simplify_leadership_fields';
import * as migration_20260731_014319_add_campaign_notifications from './20260731_014319_add_campaign_notifications';
export const migrations = [
{
@ -228,4 +229,9 @@ export const migrations = [
down: migration_20260730_043306_simplify_leadership_fields.down,
name: '20260730_043306_simplify_leadership_fields',
},
{
up: migration_20260731_014319_add_campaign_notifications.up,
down: migration_20260731_014319_add_campaign_notifications.down,
name: '20260731_014319_add_campaign_notifications'
},
];

View file

@ -71,6 +71,8 @@ export interface Config {
users: User;
campaignUser: CampaignUser;
campaignWebAuthnCredential: CampaignWebAuthnCredential;
notification: Notification;
pushSubscription: PushSubscription;
campaignInvite: CampaignInvite;
municipality: Municipality;
leadership: Leadership;
@ -107,6 +109,8 @@ export interface Config {
users: UsersSelect<false> | UsersSelect<true>;
campaignUser: CampaignUserSelect<false> | CampaignUserSelect<true>;
campaignWebAuthnCredential: CampaignWebAuthnCredentialSelect<false> | CampaignWebAuthnCredentialSelect<true>;
notification: NotificationSelect<false> | NotificationSelect<true>;
pushSubscription: PushSubscriptionSelect<false> | PushSubscriptionSelect<true>;
campaignInvite: CampaignInviteSelect<false> | CampaignInviteSelect<true>;
municipality: MunicipalitySelect<false> | MunicipalitySelect<true>;
leadership: LeadershipSelect<false> | LeadershipSelect<true>;
@ -330,82 +334,29 @@ export interface CampaignWebAuthnCredential {
createdAt: string;
}
/**
* Avisos in-app e push da campanha. Cada linha pertence a um destinatário.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignInvite".
* via the `definition` "notification".
*/
export interface CampaignInvite {
export interface Notification {
id: number;
tokenHash: string;
leadership: number | Leadership;
kind: 'login' | 'autopreenchimento';
expiresAt: string;
usedAt?: string | null;
revokedAt?: string | null;
createdBy: number | CampaignUser;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "leadership".
*/
export interface Leadership {
id: number;
contact: number | Contact;
municipalities: (number | Municipality)[];
organizations?: (number | Organization)[] | null;
stateDeputies?: (number | StateDeputy)[] | null;
exclusive?: boolean | null;
supportStatus: 'engajado' | 'a_abordar' | 'em_disputa' | 'negativo';
user?: (number | null) | CampaignUser;
consent?: (number | null) | Consent;
consentContentHash?: string | null;
consentedAt?: string | null;
notes?: string | null;
createdBy?: (number | null) | CampaignUser;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contact".
*/
export interface Contact {
id: number;
name: string;
email?: string | null;
phone?: string | null;
gender?: ('feminino' | 'masculino' | 'outro' | 'nao_informado') | null;
state:
| 'AC'
| 'AL'
| 'AM'
| 'AP'
| 'BA'
| 'CE'
| 'DF'
| 'ES'
| 'GO'
| 'MA'
| 'MG'
| 'MS'
| 'MT'
| 'PA'
| 'PB'
| 'PE'
| 'PI'
| 'PR'
| 'RJ'
| 'RN'
| 'RO'
| 'RR'
| 'RS'
| 'SC'
| 'SE'
| 'SP'
| 'TO';
city?: string | null;
postalCode?: string | null;
recipient: number | CampaignUser;
type: 'municipality_update' | 'new_supporter' | 'activity_attention' | 'invite_accepted';
/**
* Título, detalhe e href exibidos no sino e no push.
*/
payload:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
municipality?: (number | null) | Municipality;
readAt?: string | null;
updatedAt: string;
createdAt: string;
}
@ -508,19 +459,22 @@ export interface StateDeputy {
createdAt: string;
}
/**
* Sindicatos, associações, movimentos e afins. Concentra lideranças associadas e atividades apoiadas.
* Endpoints Web Push por dispositivo. Não armazena dados biométricos.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "organization".
* via the `definition` "pushSubscription".
*/
export interface Organization {
export interface PushSubscription {
id: number;
name: string;
slug: string;
kind: 'sindicato' | 'associacao' | 'religioso' | 'movimento' | 'categoria_profissional' | 'outro';
municipalities?: (number | Municipality)[] | null;
notes?: string | null;
createdBy?: (number | null) | CampaignUser;
user: number | CampaignUser;
endpoint: string;
p256dh: string;
auth: string;
expirationTime?: number | null;
consent: number | Consent;
consentContentHash: string;
consentedAt: string;
userAgent?: string | null;
updatedAt: string;
createdAt: string;
}
@ -552,6 +506,103 @@ export interface Consent {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignInvite".
*/
export interface CampaignInvite {
id: number;
tokenHash: string;
leadership: number | Leadership;
kind: 'login' | 'autopreenchimento';
expiresAt: string;
usedAt?: string | null;
revokedAt?: string | null;
createdBy: number | CampaignUser;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "leadership".
*/
export interface Leadership {
id: number;
contact: number | Contact;
municipalities: (number | Municipality)[];
organizations?: (number | Organization)[] | null;
stateDeputies?: (number | StateDeputy)[] | null;
exclusive?: boolean | null;
supportStatus: 'engajado' | 'a_abordar' | 'em_disputa' | 'negativo';
user?: (number | null) | CampaignUser;
consent?: (number | null) | Consent;
consentContentHash?: string | null;
consentedAt?: string | null;
notes?: string | null;
createdBy?: (number | null) | CampaignUser;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contact".
*/
export interface Contact {
id: number;
name: string;
email?: string | null;
phone?: string | null;
gender?: ('feminino' | 'masculino' | 'outro' | 'nao_informado') | null;
state:
| 'AC'
| 'AL'
| 'AM'
| 'AP'
| 'BA'
| 'CE'
| 'DF'
| 'ES'
| 'GO'
| 'MA'
| 'MG'
| 'MS'
| 'MT'
| 'PA'
| 'PB'
| 'PE'
| 'PI'
| 'PR'
| 'RJ'
| 'RN'
| 'RO'
| 'RR'
| 'RS'
| 'SC'
| 'SE'
| 'SP'
| 'TO';
city?: string | null;
postalCode?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* Sindicatos, associações, movimentos e afins. Concentra lideranças associadas e atividades apoiadas.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "organization".
*/
export interface Organization {
id: number;
name: string;
slug: string;
kind: 'sindicato' | 'associacao' | 'religioso' | 'movimento' | 'categoria_profissional' | 'outro';
municipalities?: (number | Municipality)[] | null;
notes?: string | null;
createdBy?: (number | null) | CampaignUser;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "votePledge".
@ -1189,6 +1240,14 @@ export interface PayloadLockedDocument {
relationTo: 'campaignWebAuthnCredential';
value: number | CampaignWebAuthnCredential;
} | null)
| ({
relationTo: 'notification';
value: number | Notification;
} | null)
| ({
relationTo: 'pushSubscription';
value: number | PushSubscription;
} | null)
| ({
relationTo: 'campaignInvite';
value: number | CampaignInvite;
@ -1398,6 +1457,36 @@ export interface CampaignWebAuthnCredentialSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "notification_select".
*/
export interface NotificationSelect<T extends boolean = true> {
recipient?: T;
type?: T;
payload?: T;
municipality?: T;
readAt?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pushSubscription_select".
*/
export interface PushSubscriptionSelect<T extends boolean = true> {
user?: T;
endpoint?: T;
p256dh?: T;
auth?: T;
expirationTime?: T;
consent?: T;
consentContentHash?: T;
consentedAt?: T;
userAgent?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignInvite_select".
@ -2226,6 +2315,8 @@ export interface TaskCreateCollectionExport {
| 'users'
| 'campaignUser'
| 'campaignWebAuthnCredential'
| 'notification'
| 'pushSubscription'
| 'campaignInvite'
| 'municipality'
| 'leadership'

View file

@ -24,9 +24,11 @@ import { Leadership } from './collections/Leadership'
import { Media } from './collections/Media'
import { Municipality } from './collections/Municipality'
import { MunicipalityUpdate } from './collections/MunicipalityUpdate'
import { Notification } from './collections/Notification'
import { Organization } from './collections/Organization'
import { Petition } from './collections/Petition'
import { Post } from './collections/Post'
import { PushSubscription } from './collections/PushSubscription'
import { Signature } from './collections/Signature'
import { StateDeputy } from './collections/StateDeputy'
import { Subscription } from './collections/Subscription'
@ -87,6 +89,8 @@ export default buildConfig({
Users,
CampaignUser,
CampaignWebAuthnCredential,
Notification,
PushSubscription,
CampaignInvite,
Municipality,
Leadership,

View file

@ -0,0 +1,27 @@
import { isCampaignUser, isPayloadAdmin, payloadAdminOnly } from '@/utilities/access/shared'
import type { Access } from 'payload'
/** A notification row belongs to exactly one recipient — never another staff account. */
export const canReadOwnNotifications: Access = ({ req }) => {
if (isPayloadAdmin(req.user)) return true
if (!isCampaignUser(req.user)) return false
return { recipient: { equals: req.user.id } }
}
export const canDeleteOwnNotifications: Access = canReadOwnNotifications
/** Created by domain hooks and server actions with admin bypass (`overrideAccess: true`). */
export const canWriteNotifications: Access = payloadAdminOnly
export const canReadOwnPushSubscriptions: Access = ({ req }) => {
if (isPayloadAdmin(req.user)) return true
if (!isCampaignUser(req.user)) return false
return { user: { equals: req.user.id } }
}
export const canDeleteOwnPushSubscriptions: Access = canReadOwnPushSubscriptions
/** Subscribe ceremony writes with admin bypass (`overrideAccess: true`) after consent checks. */
export const canWritePushSubscriptions: Access = payloadAdminOnly

View file

@ -133,3 +133,12 @@ export {
canReadOwnWebAuthnCredentials,
canWriteWebAuthnCredentials,
} from '@/utilities/access/webauthnCredentials'
export {
canDeleteOwnNotifications,
canDeleteOwnPushSubscriptions,
canReadOwnNotifications,
canReadOwnPushSubscriptions,
canWriteNotifications,
canWritePushSubscriptions,
} from '@/utilities/access/notifications'

View file

@ -4,6 +4,8 @@ import type { Payload } from 'payload'
import {
CAMPAIGN_INVITE_CONSENT_KEY,
CAMPAIGN_PUSH_CONSENT_KEY,
CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE,
SUPPORTER_REGISTRATION_CONSENT_KEY,
SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE,
SUPPORTER_VOTE_INTENTION_CONSENT_KEY,
@ -143,3 +145,15 @@ export const requireSupporterVoteIntentionConsent = (
message = SUPPORTER_VOTE_INTENTION_CONSENT_MISSING_MESSAGE,
): Promise<ConsentDescriptor> =>
requireConsentByKey(payload, SUPPORTER_VOTE_INTENTION_CONSENT_KEY, req, message)
export const getCampaignPushConsent = (
payload: Payload,
req?: ConsentRequest,
): Promise<ConsentDescriptor | null> => getConsentByKey(payload, CAMPAIGN_PUSH_CONSENT_KEY, req)
export const requireCampaignPushConsent = (
payload: Payload,
req?: ConsentRequest,
message = CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE,
): Promise<ConsentDescriptor> =>
requireConsentByKey(payload, CAMPAIGN_PUSH_CONSENT_KEY, req, message)

View file

@ -246,6 +246,7 @@ export const redeemCampaignInviteLoginRecord = async (
password: data.password,
...(data.email !== undefined ? { email: data.email } : {}),
}
const hadAccount = Boolean(account)
account = account
? await payload.update({
collection: 'campaignUser',
@ -273,6 +274,20 @@ export const redeemCampaignInviteLoginRecord = async (
overrideAccess: true,
req,
})
const updatedLeadership = await payload.findByID({
collection: 'leadership',
id: leadership.id,
depth: 0,
overrideAccess: true,
req,
})
if (!hadAccount) {
const { notifyInviteAccepted } = await import('@/utilities/notification/notificationEvents')
await notifyInviteAccepted(
{ payload, context: {}, transactionID: req.transactionID },
updatedLeadership,
)
}
const login = await payload.login({
collection: 'campaignUser',
data: { username: data.phone, password: data.password },

View file

@ -186,6 +186,46 @@ self.addEventListener('fetch', (event) => {
event.respondWith(networkFirst(request));
});
// Push / notificationclick handlers land in D2 (notifications.md).
// Push / notificationclick handlers (D2).
self.addEventListener('push', (event) => {
if (!event.data) return;
let payload;
try {
payload = event.data.json();
} catch {
return;
}
const title = payload && payload.title ? String(payload.title) : 'Campanha Jorge Solla';
const body = payload && payload.body ? String(payload.body) : '';
const url = payload && payload.url ? String(payload.url) : SCOPE;
event.waitUntil(
self.registration.showNotification(title, {
body,
icon: '/campaign-icons/icon-192.png',
badge: '/campaign-icons/icon-192.png',
data: { url },
tag: url,
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data && event.notification.data.url
? String(event.notification.data.url)
: SCOPE;
const target = url.startsWith('http') ? url : self.location.origin + url;
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if ('focus' in client && client.url.includes(SCOPE)) {
client.navigate(target);
return client.focus();
}
}
if (self.clients.openWindow) return self.clients.openWindow(target);
}),
);
});
`
}

View file

@ -0,0 +1,68 @@
import 'server-only'
/** Trusted server writes in this module use admin bypass (`overrideAccess: true`). */
import type { NotificationPayload, NotificationType } from '@/lib/notificationContract'
import type { Notification } from '@/payload-types'
import type { Payload, PayloadRequest } from 'payload'
import { sendCampaignPushForNotification } from '@/utilities/notification/sendCampaignPush'
export type NotificationWriteRequest = Pick<PayloadRequest, 'transactionID'>
type CreateCampaignNotificationInput = {
recipientID: number
type: NotificationType
payload: NotificationPayload
municipalityID?: number | null
}
const createCampaignNotification = async (
payload: Payload,
input: CreateCampaignNotificationInput,
req?: NotificationWriteRequest,
): Promise<Notification> => {
const notification = await payload.create({
collection: 'notification',
data: {
recipient: input.recipientID,
type: input.type,
payload: input.payload,
municipality: input.municipalityID ?? undefined,
},
depth: 0,
overrideAccess: true,
req,
})
queueMicrotask(() => {
void sendCampaignPushForNotification(payload, notification.id)
})
return notification
}
export const createCampaignNotifications = async (
payload: Payload,
recipientIDs: number[],
input: Omit<CreateCampaignNotificationInput, 'recipientID'>,
req?: NotificationWriteRequest,
): Promise<void> => {
const uniqueRecipients = [...new Set(recipientIDs)]
if (uniqueRecipients.length === 0) return
await Promise.all(
uniqueRecipients.map((recipientID) =>
createCampaignNotification(
payload,
{
recipientID,
type: input.type,
payload: input.payload,
municipalityID: input.municipalityID,
},
req,
),
),
)
}

View file

@ -0,0 +1,254 @@
import 'server-only'
/** Domain hooks create notifications with admin bypass (`overrideAccess: true`). */
import type { PayloadRequest } from 'payload'
import {
ENABLE_CAMPAIGN_NOTIFICATION_CONTEXT_KEY,
SKIP_CAMPAIGN_NOTIFICATION_CONTEXT_KEY,
} from '@/lib/notificationContract'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { municipalityUpdateKindLabels } from '@/lib/schemas/municipalityUpdate'
import { createCampaignNotifications } from '@/utilities/notification/createCampaignNotification'
import { resolveMunicipalityStaffRecipientIds } from '@/utilities/notification/notificationRecipients'
export type NotificationEventRequest = Pick<PayloadRequest, 'payload' | 'context' | 'transactionID'>
const shouldSkipCampaignNotification = (context: Record<string, unknown>): boolean => {
if (context[ENABLE_CAMPAIGN_NOTIFICATION_CONTEXT_KEY]) return false
if (context[SKIP_CAMPAIGN_NOTIFICATION_CONTEXT_KEY]) return true
// Int suite shares one Postgres across ~48 workers; hook side-effects (extra
// rows, coordinator fan-out reads) are tested in campaignNotifications.int.
return process.env.VITEST === 'true'
}
export const notifyMunicipalityUpdateCreated = async (
req: NotificationEventRequest,
doc: {
id: number
municipality: unknown
author: unknown
kind: string
},
): Promise<void> => {
if (shouldSkipCampaignNotification(req.context)) return
const municipalityID = relationshipId(doc.municipality)
if (!municipalityID) return
const authorID = relationshipId(doc.author)
const municipality = await req.payload.findByID({
collection: 'municipality',
id: municipalityID,
depth: 0,
select: { name: true, slug: true },
overrideAccess: true,
req,
})
let authorName = 'Equipe'
if (authorID) {
const author = await req.payload.findByID({
collection: 'campaignUser',
id: authorID,
depth: 0,
select: { name: true },
overrideAccess: true,
req,
})
authorName = author.name
}
const kindLabel =
doc.kind in municipalityUpdateKindLabels
? municipalityUpdateKindLabels[doc.kind as keyof typeof municipalityUpdateKindLabels]
: 'Atualização'
const recipientIDs = await resolveMunicipalityStaffRecipientIds(
req.payload,
municipalityID,
{ excludeUserId: authorID },
req,
)
await createCampaignNotifications(
req.payload,
recipientIDs,
{
type: 'municipality_update',
municipalityID,
payload: {
title: `${kindLabel} — ${municipality.name}`,
detail: `${authorName} registrou uma atualização`,
href: `/campanha/municipios/${municipality.slug}`,
},
},
req,
)
}
export const notifySupporterCreated = async (
req: NotificationEventRequest,
doc: { municipality: unknown; createdBy?: unknown; contact?: unknown },
): Promise<void> => {
if (shouldSkipCampaignNotification(req.context)) return
const municipalityID = relationshipId(doc.municipality)
if (!municipalityID) return
const authorID = relationshipId(doc.createdBy)
const [municipality, contactName] = await Promise.all([
req.payload.findByID({
collection: 'municipality',
id: municipalityID,
depth: 0,
select: { name: true, slug: true },
overrideAccess: true,
req,
}),
loadSupporterContactName(req, doc),
])
const recipientIDs = await resolveMunicipalityStaffRecipientIds(
req.payload,
municipalityID,
{ excludeUserId: authorID },
req,
)
await createCampaignNotifications(
req.payload,
recipientIDs,
{
type: 'new_supporter',
municipalityID,
payload: {
title: `Novo apoiador — ${municipality.name}`,
detail: contactName ? `${contactName} entrou na base` : 'Novo cadastro na base',
href: '/campanha/apoiadores',
},
},
req,
)
}
const loadSupporterContactName = async (
req: NotificationEventRequest,
doc: { contact?: unknown },
): Promise<string | null> => {
const contactID = relationshipId(doc.contact)
if (!contactID) return null
const contact = await req.payload.findByID({
collection: 'contact',
id: contactID,
depth: 0,
select: { name: true },
overrideAccess: true,
req,
})
return contact.name ?? null
}
export const notifyActivityNeedsAttention = async (
req: NotificationEventRequest,
doc: {
title: string
slug: string
municipality: unknown
advisors?: unknown
},
): Promise<void> => {
if (shouldSkipCampaignNotification(req.context)) return
const municipalityID = relationshipId(doc.municipality)
if (!municipalityID) return
const municipality = await req.payload.findByID({
collection: 'municipality',
id: municipalityID,
depth: 0,
select: { name: true },
overrideAccess: true,
req,
})
const advisorIDs = uniqueRelationshipIds(doc.advisors as readonly unknown[] | null | undefined)
const recipientIDs = [...new Set(advisorIDs)]
await createCampaignNotifications(
req.payload,
recipientIDs,
{
type: 'activity_attention',
municipalityID,
payload: {
title: `Atividade precisa de atenção — ${doc.title}`,
detail: `Em ${municipality.name}`,
href: `/campanha/atividades/${doc.slug}`,
},
},
req,
)
}
export const notifyInviteAccepted = async (
req: NotificationEventRequest,
leadership: { contact: unknown; municipalities?: unknown },
): Promise<void> => {
if (shouldSkipCampaignNotification(req.context)) return
const contactID = relationshipId(leadership.contact)
const municipalityIDs = uniqueRelationshipIds(
leadership.municipalities as readonly unknown[] | null | undefined,
)
if (municipalityIDs.length === 0) return
const contact = contactID
? await req.payload.findByID({
collection: 'contact',
id: contactID,
depth: 0,
select: { name: true },
overrideAccess: true,
req,
})
: null
const recipientIDs = await resolveMunicipalityStaffRecipientIds(
req.payload,
municipalityIDs[0],
{ includeUnrestricted: true },
req,
)
if (recipientIDs.length === 0) return
const municipality = await req.payload.findByID({
collection: 'municipality',
id: municipalityIDs[0],
depth: 0,
select: { name: true, slug: true },
overrideAccess: true,
req,
})
await createCampaignNotifications(
req.payload,
recipientIDs,
{
type: 'invite_accepted',
municipalityID: municipalityIDs[0],
payload: {
title: `Convite aceito — ${contact?.name ?? 'Nova liderança'}`,
detail: `Entrou em ${municipality.name}`,
href: '/campanha/liderancas',
},
},
req,
)
}

View file

@ -0,0 +1,68 @@
import 'server-only'
/** Trusted server reads in this module use admin bypass (`overrideAccess: true`). */
import type {
NotificationListItem,
NotificationPayload,
NotificationType,
} from '@/lib/notificationContract'
import type { Notification } from '@/payload-types'
export type { NotificationListItem } from '@/lib/notificationContract'
const isNotificationPayload = (value: unknown): value is NotificationPayload => {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
return (
typeof record.title === 'string' &&
typeof record.detail === 'string' &&
typeof record.href === 'string'
)
}
const toNotificationListItem = (doc: Notification): NotificationListItem | null => {
if (!isNotificationPayload(doc.payload)) return null
return {
id: doc.id,
type: doc.type as NotificationType,
payload: doc.payload,
readAt: doc.readAt ?? null,
createdAt: doc.createdAt,
}
}
export const countUnreadNotifications = async (
payload: import('payload').Payload,
recipientID: number,
): Promise<number> => {
const result = await payload.count({
collection: 'notification',
where: {
and: [{ recipient: { equals: recipientID } }, { readAt: { exists: false } }],
},
overrideAccess: true,
})
return result.totalDocs
}
export const loadNotificationList = async (
payload: import('payload').Payload,
recipientID: number,
limit = 40,
): Promise<NotificationListItem[]> => {
const result = await payload.find({
collection: 'notification',
where: { recipient: { equals: recipientID } },
depth: 0,
limit,
sort: '-createdAt',
overrideAccess: true,
})
return result.docs
.map((doc) => toNotificationListItem(doc))
.filter((item): item is NotificationListItem => item !== null)
}

View file

@ -0,0 +1,69 @@
import 'server-only'
/** Trusted server reads in this module use admin bypass (`overrideAccess: true`). */
import type { Payload } from 'payload'
import { uniqueRelationshipIds } from '@/lib/relationship'
import type { NotificationWriteRequest } from '@/utilities/notification/createCampaignNotification'
const UNRESTRICTED_ROLES = ['coordinator', 'candidate'] as const
const loadUnrestrictedStaffUserIds = async (
payload: Payload,
req?: NotificationWriteRequest,
): Promise<number[]> => {
const result = await payload.find({
collection: 'campaignUser',
where: { role: { in: [...UNRESTRICTED_ROLES] } },
depth: 0,
limit: 200,
pagination: false,
overrideAccess: true,
req,
})
return result.docs.map((doc) => doc.id)
}
const loadMunicipalityAdvisorUserIds = async (
payload: Payload,
municipalityID: number,
req?: NotificationWriteRequest,
): Promise<number[]> => {
const municipality = await payload.findByID({
collection: 'municipality',
id: municipalityID,
depth: 0,
select: { advisors: true },
overrideAccess: true,
req,
})
return uniqueRelationshipIds(municipality.advisors)
}
type MunicipalityStaffRecipientsOptions = {
excludeUserId?: number | null
includeUnrestricted?: boolean
}
/** Advisors of the município plus optional coordinator/candidate staff. */
export const resolveMunicipalityStaffRecipientIds = async (
payload: Payload,
municipalityID: number,
options: MunicipalityStaffRecipientsOptions = {},
req?: NotificationWriteRequest,
): Promise<number[]> => {
const { excludeUserId = null, includeUnrestricted = true } = options
const [advisorIDs, unrestrictedIDs] = await Promise.all([
loadMunicipalityAdvisorUserIds(payload, municipalityID, req),
includeUnrestricted ? loadUnrestrictedStaffUserIds(payload, req) : Promise.resolve([]),
])
const recipientIDs = [...new Set([...advisorIDs, ...unrestrictedIDs])]
if (excludeUserId === null) return recipientIDs
return recipientIDs.filter((id) => id !== excludeUserId)
}

View file

@ -0,0 +1,108 @@
import 'server-only'
/** Push delivery uses admin bypass (`overrideAccess: true`) on trusted server paths. */
import webpush from 'web-push'
import type { NotificationPayload } from '@/lib/notificationContract'
import type { Payload } from 'payload'
const resolveVapidConfig = (): {
publicKey: string
privateKey: string
subject: string
} | null => {
const publicKey = process.env.VAPID_PUBLIC_KEY?.trim()
const privateKey = process.env.VAPID_PRIVATE_KEY?.trim()
const subject = process.env.VAPID_SUBJECT?.trim() || 'mailto:campanha@jorgesolla.com.br'
if (!publicKey || !privateKey) return null
return { publicKey, privateKey, subject }
}
let vapidConfigured = false
const ensureVapidConfigured = (): boolean => {
const vapid = resolveVapidConfig()
if (!vapid) return false
if (!vapidConfigured) {
webpush.setVapidDetails(vapid.subject, vapid.publicKey, vapid.privateKey)
vapidConfigured = true
}
return true
}
export const getCampaignVapidPublicKey = (): string | null =>
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY?.trim() || process.env.VAPID_PUBLIC_KEY?.trim() || null
export const sendCampaignPushForNotification = async (
payload: Payload,
notificationID: number,
): Promise<void> => {
if (!ensureVapidConfigured()) return
const notification = await payload.findByID({
collection: 'notification',
id: notificationID,
depth: 0,
overrideAccess: true,
})
const recipientID =
typeof notification.recipient === 'number' ? notification.recipient : notification.recipient?.id
if (!recipientID) return
const content = notification.payload as NotificationPayload
if (!content?.title || !content.href) return
const subscriptions = await payload.find({
collection: 'pushSubscription',
where: { user: { equals: recipientID } },
depth: 0,
limit: 20,
pagination: false,
overrideAccess: true,
})
if (subscriptions.docs.length === 0) return
const pushPayload = JSON.stringify({
title: content.title,
body: content.detail,
url: content.href,
})
await Promise.all(
subscriptions.docs.map(async (subscription) => {
try {
await webpush.sendNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
expirationTime: subscription.expirationTime ?? undefined,
},
pushPayload,
{
TTL: 60 * 60 * 24,
},
)
} catch (error) {
const statusCode =
typeof error === 'object' && error !== null && 'statusCode' in error
? Number((error as { statusCode: unknown }).statusCode)
: null
if (statusCode === 404 || statusCode === 410) {
await payload.delete({
collection: 'pushSubscription',
id: subscription.id,
overrideAccess: true,
})
}
}
}),
)
}

View file

@ -333,6 +333,8 @@ export class CampaignFixtures {
private readonly touchedMunicipalities = new Set<number>()
private readonly ownedLockedDocuments = new Set<number>()
private readonly ownedPreferences = new Set<number>()
private readonly ownedNotifications = new Set<number>()
private readonly ownedPushSubscriptions = new Set<number>()
constructor(private readonly rootPayload: Payload) {
builderCounter += 1
@ -917,16 +919,36 @@ export class CampaignFixtures {
}
if (userIDs.length > 0) {
const updates = await this.rootPayload.find({
collection: 'municipalityUpdate',
where: { author: { in: userIDs } },
depth: 0,
pagination: false,
})
const [updates, notifications, pushSubscriptions] = await Promise.all([
this.rootPayload.find({
collection: 'municipalityUpdate',
where: { author: { in: userIDs } },
depth: 0,
pagination: false,
}),
this.rootPayload.find({
collection: 'notification',
where: { recipient: { in: userIDs } },
depth: 0,
pagination: false,
}),
this.rootPayload.find({
collection: 'pushSubscription',
where: { user: { in: userIDs } },
depth: 0,
pagination: false,
}),
])
for (const update of updates.docs) {
this.own('municipalityUpdate', update)
this.touchedMunicipalities.add(relationId(update.municipality))
}
for (const notification of notifications.docs) {
this.ownedNotifications.add(notification.id)
}
for (const subscription of pushSubscriptions.docs) {
this.ownedPushSubscriptions.add(subscription.id)
}
}
const contactIDs = [...this.owned.contact]
@ -995,6 +1017,29 @@ export class CampaignFixtures {
}
}
private async deleteNotificationOwned(req: { transactionID: number | string }): Promise<void> {
if (this.ownedNotifications.size > 0) {
await this.rootPayload.delete({
collection: 'notification',
where: { id: { in: [...this.ownedNotifications] } },
depth: 0,
overrideAccess: true,
req,
})
this.ownedNotifications.clear()
}
if (this.ownedPushSubscriptions.size > 0) {
await this.rootPayload.delete({
collection: 'pushSubscription',
where: { id: { in: [...this.ownedPushSubscriptions] } },
depth: 0,
overrideAccess: true,
req,
})
this.ownedPushSubscriptions.clear()
}
}
private async deleteOwned(
collection: CampaignCollection,
req: { transactionID: number | string },
@ -1079,12 +1124,13 @@ export class CampaignFixtures {
'organization',
'stateDeputy',
'contact',
'campaignUser',
'consent',
'users',
] as const) {
await this.deleteOwned(collection, req)
}
await this.deleteNotificationOwned(req)
for (const collection of ['campaignUser', 'consent', 'users'] as const) {
await this.deleteOwned(collection, req)
}
await this.resetTouchedMunicipalities(req)
if (this.ownedPreferences.size > 0) {
const transaction = this.rootPayload.db.sessions?.[String(req.transactionID)]?.db as

View file

@ -0,0 +1,96 @@
// @vitest-environment node
import { getPayload, type Payload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('notification access', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('denies anonymous reads', async () => {
await expect(
payload.find({ collection: 'notification', overrideAccess: false }),
).rejects.toThrow(/permissão|not allowed/i)
})
it('shows a recipient only their own notifications', async () => {
const fixtures = campaignFixtures()
const [owner, other] = await Promise.all([
fixtures.createCampaignUser('advisor'),
fixtures.createCampaignUser('advisor'),
])
const own = await payload.create({
collection: 'notification',
data: {
recipient: owner.id,
type: 'municipality_update',
payload: {
title: 'Teste — Município',
detail: 'Detalhe',
href: '/campanha/municipios/teste',
},
},
overrideAccess: true,
})
await payload.create({
collection: 'notification',
data: {
recipient: other.id,
type: 'new_supporter',
payload: {
title: 'Outro',
detail: 'Detalhe',
href: '/campanha/apoiadores',
},
},
overrideAccess: true,
})
const visible = await payload.find({
collection: 'notification',
depth: 0,
user: owner,
overrideAccess: false,
})
expect(visible.docs.map((doc) => doc.id)).toEqual([own.id])
})
})
describe('pushSubscription access', () => {
it('denies client-shaped subscription writes', async () => {
const fixtures = campaignFixtures()
const owner = await fixtures.createCampaignUser('advisor')
await expect(
payload.create({
collection: 'pushSubscription',
data: {
user: owner.id,
endpoint: 'https://push.example/1',
p256dh: 'key',
auth: 'auth',
consent: 1,
consentContentHash: 'hash',
consentedAt: new Date().toISOString(),
},
user: owner,
overrideAccess: false,
}),
).rejects.toThrow(/permissão|not allowed/i)
})
})

View file

@ -560,7 +560,7 @@ describe('admin bypasses carry a justification comment', () => {
['src/utilities/advisorData.ts', 2],
['src/utilities/campaignInviteCreation.ts', 1],
['src/utilities/campaignInvitePageData.ts', 1],
['src/utilities/campaignInviteRedemption.ts', 12],
['src/utilities/campaignInviteRedemption.ts', 13],
['src/utilities/campaignInviteRepository.ts', 2],
['src/utilities/electionCandidateOptions.ts', 1],
['src/utilities/territory/loadTerritoryOverview.ts', 1],
@ -577,7 +577,7 @@ describe('admin bypasses carry a justification comment', () => {
['src/utilities/access/webauthnCredentials.ts', 1],
['src/collections/Activity.ts', 1],
['src/collections/CampaignDemand.ts', 1],
['src/collections/CampaignUser.ts', 2],
['src/collections/CampaignUser.ts', 4],
['src/collections/CampaignWebAuthnCredential.ts', 1],
['src/collections/Media.ts', 1],
['src/collections/Municipality.ts', 1],

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { notificationTypeLabels, notificationTypes } from '@/lib/notificationContract'
import { buildCampaignServiceWorkerScript } from '@/utilities/campaignPwa'
describe('notificationContract', () => {
it('labels every notification type', () => {
for (const type of notificationTypes) {
expect(notificationTypeLabels[type]).toBeTruthy()
}
})
})
describe('buildCampaignServiceWorkerScript', () => {
it('includes push and notificationclick handlers', () => {
const script = buildCampaignServiceWorkerScript('test-build')
expect(script).toContain("self.addEventListener('push'")
expect(script).toContain("self.addEventListener('notificationclick'")
})
})