mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
D2 — Push + sino in-app para /campanha
- Collections Notification e PushSubscription com migration - Sino no header (mobile + desktop) com drawer de avisos lidos/não lidos - Hooks em municipalityUpdate, supporter, activity e convite aceito - Push Web via web-push + handlers no service worker /campanha/sw.js - Opt-in LGPD em /campanha/perfil (chave campanha-notificacoes-push) - Testes unit/int de contrato e access Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
parent
e2bc366c10
commit
de67acf41b
38 changed files with 14646 additions and 170 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: —
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
144
pnpm-lock.yaml
144
pnpm-lock.yaml
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
183
src/app/(campaign)/campanha/actions/notifications.ts
Normal file
183
src/app/(campaign)/campanha/actions/notifications.ts
Normal 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,
|
||||
})
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
78
src/collections/Notification.ts
Normal file
78
src/collections/Notification.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
94
src/collections/PushSubscription.ts
Normal file
94
src/collections/PushSubscription.ts
Normal 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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
212
src/components/campaign/auth/CampaignPushNotificationsCard.tsx
Normal file
212
src/components/campaign/auth/CampaignPushNotificationsCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
200
src/components/campaign/shell/CampaignNotificationBell.tsx
Normal file
200
src/components/campaign/shell/CampaignNotificationBell.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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.'
|
||||
|
|
|
|||
19
src/lib/campaignNotificationCopy.ts
Normal file
19
src/lib/campaignNotificationCopy.ts
Normal 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
|
||||
29
src/lib/notificationContract.ts
Normal file
29
src/lib/notificationContract.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
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',
|
||||
}
|
||||
12596
src/migrations/20260731_014319_add_campaign_notifications.json
Normal file
12596
src/migrations/20260731_014319_add_campaign_notifications.json
Normal file
File diff suppressed because it is too large
Load diff
70
src/migrations/20260731_014319_add_campaign_notifications.ts
Normal file
70
src/migrations/20260731_014319_add_campaign_notifications.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
27
src/utilities/access/notifications.ts
Normal file
27
src/utilities/access/notifications.ts
Normal 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
|
||||
|
|
@ -133,3 +133,12 @@ export {
|
|||
canReadOwnWebAuthnCredentials,
|
||||
canWriteWebAuthnCredentials,
|
||||
} from '@/utilities/access/webauthnCredentials'
|
||||
|
||||
export {
|
||||
canDeleteOwnNotifications,
|
||||
canDeleteOwnPushSubscriptions,
|
||||
canReadOwnNotifications,
|
||||
canReadOwnPushSubscriptions,
|
||||
canWriteNotifications,
|
||||
canWritePushSubscriptions,
|
||||
} from '@/utilities/access/notifications'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
`
|
||||
}
|
||||
|
|
|
|||
68
src/utilities/notification/createCampaignNotification.ts
Normal file
68
src/utilities/notification/createCampaignNotification.ts
Normal 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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
247
src/utilities/notification/notificationEvents.ts
Normal file
247
src/utilities/notification/notificationEvents.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import 'server-only'
|
||||
|
||||
/** Domain hooks create notifications with admin bypass (`overrideAccess: true`). */
|
||||
|
||||
import type { PayloadRequest } from 'payload'
|
||||
|
||||
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 SKIP_NOTIFICATION_CONTEXT = 'skipCampaignNotification'
|
||||
|
||||
const shouldSkipCampaignNotification = (context: Record<string, unknown>): boolean =>
|
||||
Boolean(context[SKIP_NOTIFICATION_CONTEXT])
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
68
src/utilities/notification/notificationList.ts
Normal file
68
src/utilities/notification/notificationList.ts
Normal 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)
|
||||
}
|
||||
69
src/utilities/notification/notificationRecipients.ts
Normal file
69
src/utilities/notification/notificationRecipients.ts
Normal 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)
|
||||
}
|
||||
108
src/utilities/notification/sendCampaignPush.ts
Normal file
108
src/utilities/notification/sendCampaignPush.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
96
tests/int/campaignNotifications.int.spec.ts
Normal file
96
tests/int/campaignNotifications.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
20
tests/unit/notificationContract.unit.spec.ts
Normal file
20
tests/unit/notificationContract.unit.spec.ts
Normal 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'")
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue