push notification implemented
need testing
This commit is contained in:
parent
26a17b5a4c
commit
0bbf2ab56f
132
backend/package-lock.json
generated
132
backend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
46
backend/scripts/generate-vapid-keys.ts
Normal file
46
backend/scripts/generate-vapid-keys.ts
Normal file
@ -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();
|
||||
@ -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],
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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<Article>,
|
||||
@Inject(forwardRef(() => StrapiService))
|
||||
private readonly strapiService: StrapiService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => PushService))
|
||||
private readonly pushService?: PushService,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateArticleDto): Promise<Article> {
|
||||
@ -183,6 +188,7 @@ export class ArticlesService {
|
||||
status: ArticleStatus = ArticleStatus.PUBLISHED,
|
||||
): Promise<Article> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
29
backend/src/modules/push/push-subscription.entity.ts
Normal file
29
backend/src/modules/push/push-subscription.entity.ts
Normal file
@ -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;
|
||||
}
|
||||
38
backend/src/modules/push/push.controller.ts
Normal file
38
backend/src/modules/push/push.controller.ts
Normal file
@ -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<void> {
|
||||
await this.pushService.unsubscribe(dto);
|
||||
}
|
||||
}
|
||||
25
backend/src/modules/push/push.dto.ts
Normal file
25
backend/src/modules/push/push.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
13
backend/src/modules/push/push.module.ts
Normal file
13
backend/src/modules/push/push.module.ts
Normal file
@ -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 {}
|
||||
173
backend/src/modules/push/push.service.ts
Normal file
173
backend/src/modules/push/push.service.ts
Normal file
@ -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<PushSubscriptionEntity>,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
const publicKey = this.configService.get<string>('VAPID_PUBLIC_KEY');
|
||||
const privateKey = this.configService.get<string>('VAPID_PRIVATE_KEY');
|
||||
const subject = this.configService.get<string>(
|
||||
'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<string>('VAPID_PUBLIC_KEY') ?? null;
|
||||
}
|
||||
|
||||
async subscribe(dto: SubscribeDto): Promise<PushSubscriptionEntity> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.sendToAll({
|
||||
title: 'Нови вести! 📰',
|
||||
body: articleTitle,
|
||||
url: `/article/${articleSlug}`,
|
||||
tag: 'new-article',
|
||||
});
|
||||
}
|
||||
|
||||
async notifyLiveBlogUpdate(
|
||||
blogTitle: string,
|
||||
blogSlug: string,
|
||||
updatePreview: string,
|
||||
): Promise<void> {
|
||||
const body =
|
||||
updatePreview.length > 100
|
||||
? updatePreview.substring(0, 97) + '...'
|
||||
: updatePreview;
|
||||
|
||||
await this.sendToAll({
|
||||
title: `${blogTitle} 📡`,
|
||||
body,
|
||||
url: `/live-blog/${blogSlug}`,
|
||||
tag: `live-blog-${blogSlug}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
157
package-lock.json
generated
157
package-lock.json
generated
@ -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",
|
||||
|
||||
84
pwa/src/components/ui/notification-banner.tsx
Normal file
84
pwa/src/components/ui/notification-banner.tsx
Normal file
@ -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 (
|
||||
<div className="fixed bottom-4 right-4 z-50 max-w-sm animate-slide-in-right">
|
||||
<div className="flex items-start gap-3 rounded-lg border-2 border-foreground bg-card p-4 shadow-brutal">
|
||||
<div className="flex-shrink-0">
|
||||
<Bell className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-body text-sm font-medium uppercase tracking-wider">
|
||||
Останете информирани
|
||||
</p>
|
||||
<p className="mt-1 font-body text-xs text-muted-foreground">
|
||||
Добијте известувања за нови статии и live ажурирања
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={handleSubscribe}
|
||||
disabled={isLoading}
|
||||
className="border-2 border-foreground bg-foreground px-3 py-1.5 font-body text-xs font-medium uppercase tracking-wider text-background transition-all hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Се вклучува...' : 'Вклучи'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="border-2 border-foreground px-3 py-1.5 font-body text-xs font-medium uppercase tracking-wider transition-all hover:bg-muted"
|
||||
>
|
||||
Не сега
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Затвори"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
pwa/src/hooks/usePushNotifications.ts
Normal file
147
pwa/src/hooks/usePushNotifications.ts
Normal file
@ -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<boolean>;
|
||||
unsubscribe: () => Promise<boolean>;
|
||||
requestPermission: () => Promise<NotificationPermission>;
|
||||
}
|
||||
|
||||
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<NotificationPermission> => {
|
||||
if (!isSupported) return 'not-supported';
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
setPermissionState(permission);
|
||||
return permission;
|
||||
}, [isSupported]);
|
||||
|
||||
const subscribe = useCallback(async (): Promise<boolean> => {
|
||||
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<boolean> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
82
pwa/src/lib/push-api.ts
Normal file
82
pwa/src/lib/push-api.ts
Normal file
@ -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<string | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@ -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({
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<NotificationBanner />
|
||||
|
||||
<footer className="border-t-4 border-foreground bg-foreground text-background">
|
||||
<div className="container mx-auto max-w-7xl px-4 py-12">
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
|
||||
@ -32,9 +32,10 @@ self.addEventListener('push', (event: PushEvent) => {
|
||||
const title = data.title ?? 'Placebo.mk'
|
||||
const options = {
|
||||
body: data.body ?? 'New update available',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
icon: data.icon ?? '/icons/icon-192.svg',
|
||||
badge: data.badge ?? '/icons/badge-72.svg',
|
||||
data: data.url ?? '/',
|
||||
tag: data.tag,
|
||||
}
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user