From 0bbf2ab56f7a0dc9ffb90c40674ee3f3663a054c Mon Sep 17 00:00:00 2001 From: echo Date: Sun, 22 Feb 2026 02:23:41 +0100 Subject: [PATCH] push notification implemented need testing --- backend/package-lock.json | 132 +++++++++---- backend/package.json | 7 +- backend/scripts/generate-vapid-keys.ts | 46 +++++ backend/src/app.module.ts | 4 + backend/src/modules/articles.module.ts | 2 + backend/src/modules/articles.service.ts | 21 +++ backend/src/modules/live-blog.module.ts | 2 + backend/src/modules/live-blog.service.ts | 21 +++ .../modules/push/push-subscription.entity.ts | 29 +++ backend/src/modules/push/push.controller.ts | 38 ++++ backend/src/modules/push/push.dto.ts | 25 +++ backend/src/modules/push/push.module.ts | 13 ++ backend/src/modules/push/push.service.ts | 173 ++++++++++++++++++ package-lock.json | 157 +++++++++++----- pwa/src/components/ui/notification-banner.tsx | 84 +++++++++ pwa/src/hooks/usePushNotifications.ts | 147 +++++++++++++++ pwa/src/lib/push-api.ts | 82 +++++++++ pwa/src/routes.tsx | 3 + pwa/src/sw.ts | 5 +- 19 files changed, 907 insertions(+), 84 deletions(-) create mode 100644 backend/scripts/generate-vapid-keys.ts create mode 100644 backend/src/modules/push/push-subscription.entity.ts create mode 100644 backend/src/modules/push/push.controller.ts create mode 100644 backend/src/modules/push/push.dto.ts create mode 100644 backend/src/modules/push/push.module.ts create mode 100644 backend/src/modules/push/push.service.ts create mode 100644 pwa/src/components/ui/notification-banner.tsx create mode 100644 pwa/src/hooks/usePushNotifications.ts create mode 100644 pwa/src/lib/push-api.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index f91738c..8ea72d2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -31,7 +31,8 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "sqlite3": "^5.1.7", - "typeorm": "^0.3.28" + "typeorm": "^0.3.28", + "web-push": "^3.6.7" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -43,6 +44,7 @@ "@types/jest": "^30.0.0", "@types/node": "^22.10.7", "@types/supertest": "^6.0.2", + "@types/web-push": "^3.6.4", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", @@ -234,7 +236,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2160,7 +2161,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2331,7 +2331,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.13.tgz", "integrity": "sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -2379,7 +2378,6 @@ "integrity": "sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2456,7 +2454,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.13.tgz", "integrity": "sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -2904,7 +2901,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -3030,7 +3026,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz", "integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3143,6 +3138,16 @@ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -3205,7 +3210,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -3894,7 +3898,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3984,7 +3987,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4199,6 +4201,18 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4401,6 +4415,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -4469,7 +4489,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4789,7 +4808,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4846,15 +4864,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -5662,7 +5678,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5723,7 +5738,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6208,6 +6222,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=4.0" }, @@ -6744,6 +6759,15 @@ "dev": true, "license": "MIT" }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -7221,7 +7245,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -8544,6 +8567,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -9292,7 +9321,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -9421,7 +9449,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -9705,7 +9732,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9795,7 +9821,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pump": { "version": "3.0.3", @@ -9946,8 +9973,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/require-directory": { "version": "2.1.1", @@ -10092,7 +10118,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -10980,7 +11005,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11322,7 +11346,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11495,7 +11518,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -11702,7 +11724,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11996,6 +12017,47 @@ "defaults": "^1.0.3" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/web-push/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/webpack": { "version": "5.105.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", @@ -12090,6 +12152,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -12108,6 +12171,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -12121,6 +12185,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -12135,6 +12200,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -12144,7 +12210,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/mime-db": { "version": "1.52.0", @@ -12152,6 +12219,7 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -12162,6 +12230,7 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -12175,6 +12244,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/backend/package.json b/backend/package.json index 1617ebe..abe7455 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,7 +24,8 @@ "dev:local": "cp -f .env.local .env && nest start --watch", "dev:reset-env": "cp -f .env.docker .env", "seed:admin": "ts-node scripts/seed-admin.ts", - "seed:categories": "ts-node scripts/seed-categories.ts" + "seed:categories": "ts-node scripts/seed-categories.ts", + "generate-vapid": "ts-node scripts/generate-vapid-keys.ts" }, "dependencies": { "@nestjs/axios": "^4.0.1", @@ -49,7 +50,8 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "sqlite3": "^5.1.7", - "typeorm": "^0.3.28" + "typeorm": "^0.3.28", + "web-push": "^3.6.7" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -61,6 +63,7 @@ "@types/jest": "^30.0.0", "@types/node": "^22.10.7", "@types/supertest": "^6.0.2", + "@types/web-push": "^3.6.4", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", diff --git a/backend/scripts/generate-vapid-keys.ts b/backend/scripts/generate-vapid-keys.ts new file mode 100644 index 0000000..ef70434 --- /dev/null +++ b/backend/scripts/generate-vapid-keys.ts @@ -0,0 +1,46 @@ +import * as webpush from 'web-push'; +import * as fs from 'fs'; +import * as path from 'path'; + +const envPath = path.join(__dirname, '..', '.env'); + +function generateVapidKeys(): void { + console.log('Generating VAPID keys...'); + + const vapidKeys = webpush.generateVAPIDKeys(); + + let envContent = ''; + + if (fs.existsSync(envPath)) { + envContent = fs.readFileSync(envPath, 'utf-8'); + } + + const lines = envContent.split('\n'); + + const vapidSubject = `VAPID_SUBJECT=mailto:contact@placebo.mk`; + const vapidPublicKey = `VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`; + const vapidPrivateKey = `VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`; + + const updatedLines = lines.filter( + (line) => + !line.startsWith('VAPID_SUBJECT=') && + !line.startsWith('VAPID_PUBLIC_KEY=') && + !line.startsWith('VAPID_PRIVATE_KEY='), + ); + + const nonEmptyLines = updatedLines.filter((line) => line.trim() !== ''); + + const newEnvContent = + nonEmptyLines.join('\n') + + (nonEmptyLines.length > 0 ? '\n' : '') + + `${vapidSubject}\n${vapidPublicKey}\n${vapidPrivateKey}\n`; + + fs.writeFileSync(envPath, newEnvContent); + + console.log('VAPID keys generated and added to .env file:'); + console.log(` Public Key: ${vapidKeys.publicKey}`); + console.log(` Private Key: ${vapidKeys.privateKey}`); + console.log('\nKeep your private key secret!'); +} + +generateVapidKeys(); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6e59ce3..bbc133f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -10,6 +10,7 @@ import { UserModule } from './modules/users/user.module'; import { AuthModule } from './modules/auth/auth.module'; import { CommentModule } from './modules/comment/comment.module'; import { AnalyticsModule } from './modules/analytics/analytics.module'; +import { PushModule } from './modules/push/push.module'; import { Article, Author, @@ -21,6 +22,7 @@ import { Reaction, } from './modules/entities'; import { ShareEvent } from './modules/analytics/analytics.entity'; +import { PushSubscriptionEntity } from './modules/push/push-subscription.entity'; @Module({ imports: [ @@ -44,6 +46,7 @@ import { ShareEvent } from './modules/analytics/analytics.entity'; Comment, Reaction, ShareEvent, + PushSubscriptionEntity, ], synchronize: process.env.DATABASE_SYNCHRONIZE === 'true', logging: process.env.DATABASE_LOGGING === 'true', @@ -55,6 +58,7 @@ import { ShareEvent } from './modules/analytics/analytics.entity'; AuthModule, CommentModule, AnalyticsModule, + PushModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/modules/articles.module.ts b/backend/src/modules/articles.module.ts index 52a8d05..e14dd3f 100644 --- a/backend/src/modules/articles.module.ts +++ b/backend/src/modules/articles.module.ts @@ -4,11 +4,13 @@ import { ArticlesService } from './articles.service'; import { ArticlesController } from './articles.controller'; import { Article, Author, Category } from './entities'; import { StrapiModule } from './strapi.module'; +import { PushModule } from './push/push.module'; @Module({ imports: [ TypeOrmModule.forFeature([Article, Author, Category]), forwardRef(() => StrapiModule), + forwardRef(() => PushModule), ], controllers: [ArticlesController], providers: [ArticlesService], diff --git a/backend/src/modules/articles.service.ts b/backend/src/modules/articles.service.ts index 583d5c8..4bd1085 100644 --- a/backend/src/modules/articles.service.ts +++ b/backend/src/modules/articles.service.ts @@ -4,6 +4,7 @@ import { Logger, Inject, forwardRef, + Optional, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -14,6 +15,7 @@ import { UpdateArticleDto, FindArticlesDto, } from './articles.dto'; +import { PushService } from './push/push.service'; @Injectable() export class ArticlesService { @@ -24,6 +26,9 @@ export class ArticlesService { private readonly articleRepository: Repository
, @Inject(forwardRef(() => StrapiService)) private readonly strapiService: StrapiService, + @Optional() + @Inject(forwardRef(() => PushService)) + private readonly pushService?: PushService, ) {} async create(dto: CreateArticleDto): Promise
{ @@ -183,6 +188,7 @@ export class ArticlesService { status: ArticleStatus = ArticleStatus.PUBLISHED, ): Promise
{ const article = await this.findOne(id); + const wasDraft = article.status === ArticleStatus.DRAFT; article.status = status; const savedArticle = await this.articleRepository.save(article); @@ -202,6 +208,21 @@ export class ArticlesService { } } + // Send push notification for newly published articles + if (wasDraft && status === ArticleStatus.PUBLISHED && this.pushService) { + try { + await this.pushService.notifyNewArticle( + savedArticle.title, + savedArticle.slug, + ); + this.logger.log( + `Push notification sent for article: ${savedArticle.title}`, + ); + } catch (error) { + this.logger.error('Failed to send push notification:', error); + } + } + return savedArticle; } diff --git a/backend/src/modules/live-blog.module.ts b/backend/src/modules/live-blog.module.ts index f385afc..dbfb2ed 100644 --- a/backend/src/modules/live-blog.module.ts +++ b/backend/src/modules/live-blog.module.ts @@ -5,12 +5,14 @@ import { LiveBlogService } from './live-blog.service'; import { LiveBlogController } from './live-blog.controller'; import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities'; import { StrapiModule } from './strapi.module'; +import { PushModule } from './push/push.module'; @Module({ imports: [ TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]), EventEmitterModule.forRoot(), forwardRef(() => StrapiModule), + forwardRef(() => PushModule), ], controllers: [LiveBlogController], providers: [LiveBlogService], diff --git a/backend/src/modules/live-blog.service.ts b/backend/src/modules/live-blog.service.ts index 4230882..55f3279 100644 --- a/backend/src/modules/live-blog.service.ts +++ b/backend/src/modules/live-blog.service.ts @@ -5,6 +5,7 @@ import { OnModuleInit, Inject, forwardRef, + Optional, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; @@ -19,6 +20,7 @@ import { CreateLiveBlogUpdateDto, UpdateLiveBlogUpdateDto, } from './articles.dto'; +import { PushService } from './push/push.service'; interface SseClient { id: string; @@ -55,6 +57,9 @@ export class LiveBlogService implements OnModuleInit { private readonly eventEmitter: EventEmitter2, @Inject(forwardRef(() => StrapiService)) private readonly strapiService: StrapiService, + @Optional() + @Inject(forwardRef(() => PushService)) + private readonly pushService?: PushService, ) {} onModuleInit() { @@ -364,6 +369,22 @@ export class LiveBlogService implements OnModuleInit { update: savedUpdate, }); + // Send push notification for live blog updates (only for live blogs) + if (liveBlogEntity.status === LiveBlogStatus.LIVE && this.pushService) { + try { + await this.pushService.notifyLiveBlogUpdate( + liveBlogEntity.title, + liveBlogEntity.slug, + savedUpdate.content, + ); + this.logger.debug( + `Push notification sent for live blog update: ${liveBlogEntity.title}`, + ); + } catch (error) { + this.logger.error('Failed to send push notification:', error); + } + } + return savedUpdate; } diff --git a/backend/src/modules/push/push-subscription.entity.ts b/backend/src/modules/push/push-subscription.entity.ts new file mode 100644 index 0000000..5b4a4de --- /dev/null +++ b/backend/src/modules/push/push-subscription.entity.ts @@ -0,0 +1,29 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity('push_subscriptions') +export class PushSubscriptionEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ type: 'text', unique: true }) + endpoint: string; + + @Column({ type: 'text' }) + p256dh: string; + + @Column({ type: 'text' }) + auth: string; + + @Column({ type: 'varchar', nullable: true }) + userId: string | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/modules/push/push.controller.ts b/backend/src/modules/push/push.controller.ts new file mode 100644 index 0000000..cc0d1f8 --- /dev/null +++ b/backend/src/modules/push/push.controller.ts @@ -0,0 +1,38 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { PushService } from './push.service'; +import { SubscribeDto, UnsubscribeDto } from './push.dto'; +import { Public } from '../auth/public.decorator'; + +@Controller('push') +export class PushController { + constructor(private readonly pushService: PushService) {} + + @Public() + @Get('vapid-public-key') + getVapidPublicKey(): { publicKey: string | null } { + return { publicKey: this.pushService.getPublicKey() }; + } + + @Public() + @Post('subscribe') + @HttpCode(HttpStatus.CREATED) + async subscribe(@Body() dto: SubscribeDto): Promise<{ success: boolean }> { + await this.pushService.subscribe(dto); + return { success: true }; + } + + @Public() + @Delete('unsubscribe') + @HttpCode(HttpStatus.NO_CONTENT) + async unsubscribe(@Body() dto: UnsubscribeDto): Promise { + await this.pushService.unsubscribe(dto); + } +} diff --git a/backend/src/modules/push/push.dto.ts b/backend/src/modules/push/push.dto.ts new file mode 100644 index 0000000..25f95b5 --- /dev/null +++ b/backend/src/modules/push/push.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsNotEmpty, IsOptional, IsUrl } from 'class-validator'; + +export class SubscribeDto { + @IsUrl() + @IsNotEmpty() + endpoint: string; + + @IsString() + @IsNotEmpty() + p256dh: string; + + @IsString() + @IsNotEmpty() + auth: string; + + @IsOptional() + @IsString() + userId?: string; +} + +export class UnsubscribeDto { + @IsUrl() + @IsNotEmpty() + endpoint: string; +} diff --git a/backend/src/modules/push/push.module.ts b/backend/src/modules/push/push.module.ts new file mode 100644 index 0000000..e3fef17 --- /dev/null +++ b/backend/src/modules/push/push.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PushController } from './push.controller'; +import { PushService } from './push.service'; +import { PushSubscriptionEntity } from './push-subscription.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([PushSubscriptionEntity])], + controllers: [PushController], + providers: [PushService], + exports: [PushService], +}) +export class PushModule {} diff --git a/backend/src/modules/push/push.service.ts b/backend/src/modules/push/push.service.ts new file mode 100644 index 0000000..2253fe4 --- /dev/null +++ b/backend/src/modules/push/push.service.ts @@ -0,0 +1,173 @@ +import * as webpush from 'web-push'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PushSubscriptionEntity } from './push-subscription.entity'; +import { SubscribeDto, UnsubscribeDto } from './push.dto'; + +export interface PushPayload { + title: string; + body: string; + icon?: string; + badge?: string; + url?: string; + tag?: string; +} + +@Injectable() +export class PushService implements OnModuleInit { + private readonly logger = new Logger(PushService.name); + + constructor( + private configService: ConfigService, + @InjectRepository(PushSubscriptionEntity) + private subscriptionRepo: Repository, + ) {} + + onModuleInit() { + const publicKey = this.configService.get('VAPID_PUBLIC_KEY'); + const privateKey = this.configService.get('VAPID_PRIVATE_KEY'); + const subject = this.configService.get( + 'VAPID_SUBJECT', + 'mailto:contact@placebo.mk', + ); + + if (!publicKey || !privateKey) { + this.logger.warn( + 'VAPID keys not configured. Push notifications will not work. Run: npm run generate-vapid', + ); + return; + } + + webpush.setVapidDetails(subject, publicKey, privateKey); + this.logger.log('VAPID keys configured successfully'); + } + + getPublicKey(): string | null { + return this.configService.get('VAPID_PUBLIC_KEY') ?? null; + } + + async subscribe(dto: SubscribeDto): Promise { + const existing = await this.subscriptionRepo.findOne({ + where: { endpoint: dto.endpoint }, + }); + + if (existing) { + if (dto.userId && existing.userId !== dto.userId) { + existing.userId = dto.userId; + return this.subscriptionRepo.save(existing); + } + return existing; + } + + const subscription = this.subscriptionRepo.create({ + endpoint: dto.endpoint, + p256dh: dto.p256dh, + auth: dto.auth, + userId: dto.userId ?? null, + }); + + return this.subscriptionRepo.save(subscription); + } + + async unsubscribe(dto: UnsubscribeDto): Promise { + await this.subscriptionRepo.delete({ endpoint: dto.endpoint }); + } + + async sendToAll( + payload: PushPayload, + ): Promise<{ sent: number; failed: number }> { + const subscriptions = await this.subscriptionRepo.find(); + + if (subscriptions.length === 0) { + return { sent: 0, failed: 0 }; + } + + const notification = JSON.stringify({ + title: payload.title, + body: payload.body, + icon: payload.icon ?? '/icons/icon-192.png', + badge: payload.badge ?? '/icons/badge-72.png', + url: payload.url ?? '/', + tag: payload.tag, + }); + + let sent = 0; + let failed = 0; + + const results = await Promise.allSettled( + subscriptions.map(async (sub) => { + try { + await webpush.sendNotification( + { + endpoint: sub.endpoint, + keys: { + p256dh: sub.p256dh, + auth: sub.auth, + }, + }, + notification, + { + TTL: 86400, + }, + ); + return { success: true, id: sub.id }; + } catch (error) { + if (error instanceof Error) { + this.logger.warn(`Push failed for ${sub.id}: ${error.message}`); + } + if ( + error instanceof webpush.WebPushError && + (error.statusCode === 410 || error.statusCode === 404) + ) { + await this.subscriptionRepo.delete({ id: sub.id }); + this.logger.log(`Removed invalid subscription: ${sub.id}`); + } + return { success: false, id: sub.id }; + } + }), + ); + + for (const result of results) { + if (result.status === 'fulfilled' && result.value.success) { + sent++; + } else { + failed++; + } + } + + this.logger.log(`Push sent: ${sent}, failed: ${failed}`); + return { sent, failed }; + } + + async notifyNewArticle( + articleTitle: string, + articleSlug: string, + ): Promise { + await this.sendToAll({ + title: 'Нови вести! 📰', + body: articleTitle, + url: `/article/${articleSlug}`, + tag: 'new-article', + }); + } + + async notifyLiveBlogUpdate( + blogTitle: string, + blogSlug: string, + updatePreview: string, + ): Promise { + const body = + updatePreview.length > 100 + ? updatePreview.substring(0, 97) + '...' + : updatePreview; + + await this.sendToAll({ + title: `${blogTitle} 📡`, + body, + url: `/live-blog/${blogSlug}`, + tag: `live-blog-${blogSlug}`, + }); + } +} diff --git a/package-lock.json b/package-lock.json index 869a066..fc5c8b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,8 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "sqlite3": "^5.1.7", - "typeorm": "^0.3.28" + "typeorm": "^0.3.28", + "web-push": "^3.6.7" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -54,6 +55,7 @@ "@types/jest": "^30.0.0", "@types/node": "^22.10.7", "@types/supertest": "^6.0.2", + "@types/web-push": "^3.6.4", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", @@ -5249,10 +5251,6 @@ "wrappy": "1" } }, - "backend/node_modules/inherits": { - "version": "2.0.4", - "license": "ISC" - }, "backend/node_modules/ini": { "version": "1.3.8", "license": "ISC" @@ -6520,13 +6518,6 @@ "node": "*" } }, - "backend/node_modules/minimist": { - "version": "1.2.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "backend/node_modules/minipass": { "version": "7.1.2", "license": "ISC", @@ -7610,10 +7601,6 @@ "node": ">= 18" } }, - "backend/node_modules/safer-buffer": { - "version": "2.1.2", - "license": "MIT" - }, "backend/node_modules/schema-utils": { "version": "3.3.0", "dev": true, @@ -14119,16 +14106,6 @@ "version": "2.0.6", "license": "MIT" }, - "cms/cms/node_modules/asn1.js": { - "version": "5.4.1", - "license": "MIT", - "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" - } - }, "cms/cms/node_modules/ast-types": { "version": "0.16.1", "license": "MIT", @@ -14254,10 +14231,6 @@ "node": ">= 6" } }, - "cms/cms/node_modules/bn.js": { - "version": "4.12.2", - "license": "MIT" - }, "cms/cms/node_modules/boolbase": { "version": "1.0.0", "license": "ISC" @@ -17240,10 +17213,6 @@ "wrappy": "1" } }, - "cms/cms/node_modules/inherits": { - "version": "2.0.4", - "license": "ISC" - }, "cms/cms/node_modules/ini": { "version": "1.3.8", "license": "ISC" @@ -18716,10 +18685,6 @@ "webpack": "^5.0.0" } }, - "cms/cms/node_modules/minimalistic-assert": { - "version": "1.0.1", - "license": "ISC" - }, "cms/cms/node_modules/minimalistic-crypto-utils": { "version": "1.0.1", "license": "MIT" @@ -18737,13 +18702,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "cms/cms/node_modules/minimist": { - "version": "1.2.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "cms/cms/node_modules/minipass": { "version": "7.1.2", "license": "ISC", @@ -21241,10 +21199,6 @@ "node": ">=10" } }, - "cms/cms/node_modules/safer-buffer": { - "version": "2.1.2", - "license": "MIT" - }, "cms/cms/node_modules/sanitize-html": { "version": "2.13.0", "license": "MIT", @@ -27112,12 +27066,31 @@ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -27142,6 +27115,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/backend": { "resolved": "backend", "link": true @@ -27170,6 +27155,12 @@ "node": ">= 18" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -27563,6 +27554,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -27583,6 +27596,12 @@ ], "license": "BSD-3-Clause" }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -28632,6 +28651,21 @@ ], "license": "MIT" }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -29024,6 +29058,12 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -29377,6 +29417,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/pwa/src/components/ui/notification-banner.tsx b/pwa/src/components/ui/notification-banner.tsx new file mode 100644 index 0000000..0ea7310 --- /dev/null +++ b/pwa/src/components/ui/notification-banner.tsx @@ -0,0 +1,84 @@ +import { useState, useEffect } from 'react'; +import { usePushNotifications } from '@/hooks/usePushNotifications'; +import { Bell, X } from 'lucide-react'; + +const DISMISSED_KEY = 'push-banner-dismissed'; +const SHOW_DELAY = 3000; + +function getInitiallyDismissed(): boolean { + if (typeof window === 'undefined') return false; + return !!localStorage.getItem(DISMISSED_KEY); +} + +export function NotificationBanner() { + const [isVisible, setIsVisible] = useState(false); + const [isDismissed, setIsDismissed] = useState(getInitiallyDismissed); + const { isSupported, isSubscribed, isLoading, subscribe } = + usePushNotifications(); + + useEffect(() => { + if (isSupported && !isSubscribed && !isDismissed) { + const timer = setTimeout(() => { + setIsVisible(true); + }, SHOW_DELAY); + return () => clearTimeout(timer); + } + }, [isSupported, isSubscribed, isDismissed]); + + const handleDismiss = () => { + setIsVisible(false); + localStorage.setItem(DISMISSED_KEY, 'true'); + setIsDismissed(true); + }; + + const handleSubscribe = async () => { + const success = await subscribe(); + if (success) { + setIsVisible(false); + } + }; + + if (!isVisible || isSubscribed || isDismissed) { + return null; + } + + return ( +
+
+
+ +
+
+

+ Останете информирани +

+

+ Добијте известувања за нови статии и live ажурирања +

+
+ + +
+
+ +
+
+ ); +} diff --git a/pwa/src/hooks/usePushNotifications.ts b/pwa/src/hooks/usePushNotifications.ts new file mode 100644 index 0000000..0bb5ad4 --- /dev/null +++ b/pwa/src/hooks/usePushNotifications.ts @@ -0,0 +1,147 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + getVapidPublicKey, + subscribeToPush, + unsubscribeFromPush, + urlBase64ToUint8Array, +} from '@/lib/push-api'; +import type { PushSubscriptionData } from '@/lib/push-api'; + +const STORAGE_KEY = 'push-notification-preference'; + +function checkPushSupport(): boolean { + return ( + typeof window !== 'undefined' && + 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window + ); +} + +function getInitialPermission(): NotificationPermission | 'not-supported' { + if (!checkPushSupport()) return 'not-supported'; + return Notification.permission; +} + +export interface UsePushNotificationsReturn { + isSupported: boolean; + isSubscribed: boolean; + isLoading: boolean; + permissionState: NotificationPermission | 'not-supported'; + subscribe: () => Promise; + unsubscribe: () => Promise; + requestPermission: () => Promise; +} + +export function usePushNotifications(): UsePushNotificationsReturn { + const [isSupported] = useState(checkPushSupport); + const [isSubscribed, setIsSubscribed] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [permissionState, setPermissionState] = useState(getInitialPermission); + const hasCheckedRef = useRef(false); + + useEffect(() => { + if (!isSupported || hasCheckedRef.current) return; + hasCheckedRef.current = true; + + navigator.serviceWorker.ready + .then((registration) => registration.pushManager.getSubscription()) + .then((subscription) => { + setIsSubscribed(!!subscription); + }) + .catch((error) => { + console.error('Error checking subscription:', error); + }); + }, [isSupported]); + + const requestPermission = + useCallback(async (): Promise => { + if (!isSupported) return 'not-supported'; + + const permission = await Notification.requestPermission(); + setPermissionState(permission); + return permission; + }, [isSupported]); + + const subscribe = useCallback(async (): Promise => { + if (!isSupported) return false; + + setIsLoading(true); + + try { + const permission = await requestPermission(); + if (permission !== 'granted') { + setIsLoading(false); + return false; + } + + const publicKey = await getVapidPublicKey(); + if (!publicKey) { + console.error('VAPID public key not available'); + setIsLoading(false); + return false; + } + + const registration = await navigator.serviceWorker.ready; + + let subscription = await registration.pushManager.getSubscription(); + + if (!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + } + + const subJson = subscription.toJSON() as PushSubscriptionData; + const success = await subscribeToPush(subJson); + + if (success) { + setIsSubscribed(true); + localStorage.setItem(STORAGE_KEY, 'subscribed'); + } + + setIsLoading(false); + return success; + } catch (error) { + console.error('Error subscribing to push notifications:', error); + setIsLoading(false); + return false; + } + }, [isSupported, requestPermission]); + + const unsubscribe = useCallback(async (): Promise => { + if (!isSupported) return false; + + setIsLoading(true); + + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + + if (subscription) { + await unsubscribeFromPush(subscription.endpoint); + await subscription.unsubscribe(); + } + + setIsSubscribed(false); + localStorage.removeItem(STORAGE_KEY); + setIsLoading(false); + return true; + } catch (error) { + console.error('Error unsubscribing from push notifications:', error); + setIsLoading(false); + return false; + } + }, [isSupported]); + + return { + isSupported, + isSubscribed, + isLoading, + permissionState, + subscribe, + unsubscribe, + requestPermission, + }; +} diff --git a/pwa/src/lib/push-api.ts b/pwa/src/lib/push-api.ts new file mode 100644 index 0000000..f8643ae --- /dev/null +++ b/pwa/src/lib/push-api.ts @@ -0,0 +1,82 @@ +export interface PushSubscriptionData { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; +} + +export interface VapidPublicKeyResponse { + publicKey: string | null; +} + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1'; + +export async function getVapidPublicKey(): Promise { + try { + const response = await fetch(`${API_BASE_URL}/push/vapid-public-key`); + if (!response.ok) { + throw new Error('Failed to fetch VAPID public key'); + } + const data: VapidPublicKeyResponse = await response.json(); + return data.publicKey; + } catch (error) { + console.error('Error fetching VAPID public key:', error); + return null; + } +} + +export async function subscribeToPush( + subscription: PushSubscriptionData, +): Promise { + try { + const response = await fetch(`${API_BASE_URL}/push/subscribe`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + endpoint: subscription.endpoint, + p256dh: subscription.keys.p256dh, + auth: subscription.keys.auth, + }), + }); + return response.ok; + } catch (error) { + console.error('Error subscribing to push:', error); + return false; + } +} + +export async function unsubscribeFromPush( + endpoint: string, +): Promise { + try { + const response = await fetch(`${API_BASE_URL}/push/unsubscribe`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ endpoint }), + }); + return response.ok; + } catch (error) { + console.error('Error unsubscribing from push:', error); + return false; + } +} + +export function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} diff --git a/pwa/src/routes.tsx b/pwa/src/routes.tsx index 4b1c509..61e03d3 100644 --- a/pwa/src/routes.tsx +++ b/pwa/src/routes.tsx @@ -16,6 +16,7 @@ import { Header } from './components/layout/Header' import { HeroArticle } from './components/home/HeroArticle' import { PinnedLiveBlogsSidebar } from './components/home/PinnedLiveBlogsSidebar' import { LatestArticlesGrid } from './components/home/LatestArticlesGrid' +import { NotificationBanner } from './components/ui/notification-banner' import { Button } from './components/ui/button' import { Zap, Search, Users } from 'lucide-react' import './styles.css' @@ -38,6 +39,8 @@ const rootRoute = createRootRoute({ + +