3 Commits
Author SHA1 Message Date
dimitar 57864e00da push notification
fix implemented
2026-02-22 04:00:20 +01:00
dimitar 0bbf2ab56f push notification implemented
need testing
2026-02-22 02:23:41 +01:00
dimitar 26a17b5a4c Merge branch 'pwa' 2026-02-22 01:27:13 +01:00
28 changed files with 6015 additions and 92 deletions

No files matched your search

+101 -31
View File
@@ -31,7 +31,8 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"typeorm": "^0.3.28" "typeorm": "^0.3.28",
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@@ -43,6 +44,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/web-push": "^3.6.4",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
@@ -234,7 +236,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -2160,7 +2161,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -2331,7 +2331,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.13.tgz", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.13.tgz",
"integrity": "sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==", "integrity": "sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"file-type": "21.3.0", "file-type": "21.3.0",
"iterare": "1.2.1", "iterare": "1.2.1",
@@ -2379,7 +2378,6 @@
"integrity": "sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==", "integrity": "sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@nuxt/opencollective": "0.4.1", "@nuxt/opencollective": "0.4.1",
"fast-safe-stringify": "2.1.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", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.13.tgz",
"integrity": "sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==", "integrity": "sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cors": "2.8.6", "cors": "2.8.6",
"express": "5.2.1", "express": "5.2.1",
@@ -2904,7 +2901,6 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "*", "@types/estree": "*",
"@types/json-schema": "*" "@types/json-schema": "*"
@@ -3030,7 +3026,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz",
"integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==", "integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -3143,6 +3138,16 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT" "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": { "node_modules/@types/yargs": {
"version": "17.0.35", "version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -3205,7 +3210,6 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0", "@typescript-eslint/types": "8.54.0",
@@ -3894,7 +3898,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3984,7 +3987,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -4199,6 +4201,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -4401,6 +4415,12 @@
"readable-stream": "^3.4.0" "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": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -4469,7 +4489,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -4789,7 +4808,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"readdirp": "^4.0.1" "readdirp": "^4.0.1"
}, },
@@ -4846,15 +4864,13 @@
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/class-validator": { "node_modules/class-validator": {
"version": "0.14.3", "version": "0.14.3",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/validator": "^13.15.3", "@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1", "libphonenumber-js": "^1.11.1",
@@ -5662,7 +5678,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5723,7 +5738,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"eslint-config-prettier": "bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
@@ -6208,6 +6222,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=4.0" "node": ">=4.0"
}, },
@@ -6744,6 +6759,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -7221,7 +7245,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "30.2.0", "@jest/core": "30.2.0",
"@jest/types": "30.2.0", "@jest/types": "30.2.0",
@@ -8544,6 +8567,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "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", "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"passport-strategy": "1.x.x", "passport-strategy": "1.x.x",
"pause": "0.0.1", "pause": "0.0.1",
@@ -9421,7 +9449,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.11.0", "pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0", "pg-pool": "^3.11.0",
@@ -9705,7 +9732,6 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -9795,7 +9821,8 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.3", "version": "3.0.3",
@@ -9946,8 +9973,7 @@
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0", "license": "Apache-2.0"
"peer": true
}, },
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
@@ -10092,7 +10118,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@@ -10980,7 +11005,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -11322,7 +11346,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@@ -11495,7 +11518,6 @@
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz",
"integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@sqltools/formatter": "^1.2.5", "@sqltools/formatter": "^1.2.5",
"ansis": "^4.2.0", "ansis": "^4.2.0",
@@ -11702,7 +11724,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -11996,6 +12017,47 @@
"defaults": "^1.0.3" "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": { "node_modules/webpack": {
"version": "5.105.0", "version": "5.105.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz",
@@ -12090,6 +12152,7 @@
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ajv": "^8.0.0" "ajv": "^8.0.0"
}, },
@@ -12108,6 +12171,7 @@
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3" "fast-deep-equal": "^3.1.3"
}, },
@@ -12121,6 +12185,7 @@
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"esrecurse": "^4.3.0", "esrecurse": "^4.3.0",
"estraverse": "^4.1.1" "estraverse": "^4.1.1"
@@ -12135,6 +12200,7 @@
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"engines": { "engines": {
"node": ">=4.0" "node": ">=4.0"
} }
@@ -12144,7 +12210,8 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/webpack/node_modules/mime-db": { "node_modules/webpack/node_modules/mime-db": {
"version": "1.52.0", "version": "1.52.0",
@@ -12152,6 +12219,7 @@
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
@@ -12162,6 +12230,7 @@
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mime-db": "1.52.0" "mime-db": "1.52.0"
}, },
@@ -12175,6 +12244,7 @@
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/json-schema": "^7.0.9", "@types/json-schema": "^7.0.9",
"ajv": "^8.9.0", "ajv": "^8.9.0",
+5 -2
View File
@@ -24,7 +24,8 @@
"dev:local": "cp -f .env.local .env && nest start --watch", "dev:local": "cp -f .env.local .env && nest start --watch",
"dev:reset-env": "cp -f .env.docker .env", "dev:reset-env": "cp -f .env.docker .env",
"seed:admin": "ts-node scripts/seed-admin.ts", "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": { "dependencies": {
"@nestjs/axios": "^4.0.1", "@nestjs/axios": "^4.0.1",
@@ -49,7 +50,8 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"typeorm": "^0.3.28" "typeorm": "^0.3.28",
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@@ -61,6 +63,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/web-push": "^3.6.4",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
+46
View 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();
+4
View File
@@ -10,6 +10,7 @@ import { UserModule } from './modules/users/user.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { CommentModule } from './modules/comment/comment.module'; import { CommentModule } from './modules/comment/comment.module';
import { AnalyticsModule } from './modules/analytics/analytics.module'; import { AnalyticsModule } from './modules/analytics/analytics.module';
import { PushModule } from './modules/push/push.module';
import { import {
Article, Article,
Author, Author,
@@ -21,6 +22,7 @@ import {
Reaction, Reaction,
} from './modules/entities'; } from './modules/entities';
import { ShareEvent } from './modules/analytics/analytics.entity'; import { ShareEvent } from './modules/analytics/analytics.entity';
import { PushSubscriptionEntity } from './modules/push/push-subscription.entity';
@Module({ @Module({
imports: [ imports: [
@@ -44,6 +46,7 @@ import { ShareEvent } from './modules/analytics/analytics.entity';
Comment, Comment,
Reaction, Reaction,
ShareEvent, ShareEvent,
PushSubscriptionEntity,
], ],
synchronize: process.env.DATABASE_SYNCHRONIZE === 'true', synchronize: process.env.DATABASE_SYNCHRONIZE === 'true',
logging: process.env.DATABASE_LOGGING === 'true', logging: process.env.DATABASE_LOGGING === 'true',
@@ -55,6 +58,7 @@ import { ShareEvent } from './modules/analytics/analytics.entity';
AuthModule, AuthModule,
CommentModule, CommentModule,
AnalyticsModule, AnalyticsModule,
PushModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
+2
View File
@@ -4,11 +4,13 @@ import { ArticlesService } from './articles.service';
import { ArticlesController } from './articles.controller'; import { ArticlesController } from './articles.controller';
import { Article, Author, Category } from './entities'; import { Article, Author, Category } from './entities';
import { StrapiModule } from './strapi.module'; import { StrapiModule } from './strapi.module';
import { PushModule } from './push/push.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Article, Author, Category]), TypeOrmModule.forFeature([Article, Author, Category]),
forwardRef(() => StrapiModule), forwardRef(() => StrapiModule),
forwardRef(() => PushModule),
], ],
controllers: [ArticlesController], controllers: [ArticlesController],
providers: [ArticlesService], providers: [ArticlesService],
+21
View File
@@ -4,6 +4,7 @@ import {
Logger, Logger,
Inject, Inject,
forwardRef, forwardRef,
Optional,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -14,6 +15,7 @@ import {
UpdateArticleDto, UpdateArticleDto,
FindArticlesDto, FindArticlesDto,
} from './articles.dto'; } from './articles.dto';
import { PushService } from './push/push.service';
@Injectable() @Injectable()
export class ArticlesService { export class ArticlesService {
@@ -24,6 +26,9 @@ export class ArticlesService {
private readonly articleRepository: Repository<Article>, private readonly articleRepository: Repository<Article>,
@Inject(forwardRef(() => StrapiService)) @Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService, private readonly strapiService: StrapiService,
@Optional()
@Inject(forwardRef(() => PushService))
private readonly pushService?: PushService,
) {} ) {}
async create(dto: CreateArticleDto): Promise<Article> { async create(dto: CreateArticleDto): Promise<Article> {
@@ -183,6 +188,7 @@ export class ArticlesService {
status: ArticleStatus = ArticleStatus.PUBLISHED, status: ArticleStatus = ArticleStatus.PUBLISHED,
): Promise<Article> { ): Promise<Article> {
const article = await this.findOne(id); const article = await this.findOne(id);
const wasDraft = article.status === ArticleStatus.DRAFT;
article.status = status; article.status = status;
const savedArticle = await this.articleRepository.save(article); 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; return savedArticle;
} }
+2
View File
@@ -5,12 +5,14 @@ import { LiveBlogService } from './live-blog.service';
import { LiveBlogController } from './live-blog.controller'; import { LiveBlogController } from './live-blog.controller';
import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities'; import { LiveBlog, LiveBlogUpdate, Author, Category } from './entities';
import { StrapiModule } from './strapi.module'; import { StrapiModule } from './strapi.module';
import { PushModule } from './push/push.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]), TypeOrmModule.forFeature([LiveBlog, LiveBlogUpdate, Author, Category]),
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
forwardRef(() => StrapiModule), forwardRef(() => StrapiModule),
forwardRef(() => PushModule),
], ],
controllers: [LiveBlogController], controllers: [LiveBlogController],
providers: [LiveBlogService], providers: [LiveBlogService],
+21
View File
@@ -5,6 +5,7 @@ import {
OnModuleInit, OnModuleInit,
Inject, Inject,
forwardRef, forwardRef,
Optional,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm'; import { Repository, In } from 'typeorm';
@@ -19,6 +20,7 @@ import {
CreateLiveBlogUpdateDto, CreateLiveBlogUpdateDto,
UpdateLiveBlogUpdateDto, UpdateLiveBlogUpdateDto,
} from './articles.dto'; } from './articles.dto';
import { PushService } from './push/push.service';
interface SseClient { interface SseClient {
id: string; id: string;
@@ -55,6 +57,9 @@ export class LiveBlogService implements OnModuleInit {
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
@Inject(forwardRef(() => StrapiService)) @Inject(forwardRef(() => StrapiService))
private readonly strapiService: StrapiService, private readonly strapiService: StrapiService,
@Optional()
@Inject(forwardRef(() => PushService))
private readonly pushService?: PushService,
) {} ) {}
onModuleInit() { onModuleInit() {
@@ -364,6 +369,22 @@ export class LiveBlogService implements OnModuleInit {
update: savedUpdate, 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; return savedUpdate;
} }
@@ -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;
}
@@ -0,0 +1,69 @@
import {
Controller,
Get,
Post,
Delete,
Body,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { PushService } from './push.service';
import { SubscribeDto, UnsubscribeDto, SendNotificationDto } from './push.dto';
import { Public } from '../auth/public.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { UserRole } from '../entities';
@Controller('push')
@UseGuards(RolesGuard)
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 }> {
console.log('Received push subscription:', {
endpoint: dto.endpoint?.substring(0, 50) + '...',
hasP256dh: !!dto.p256dh,
hasAuth: !!dto.auth,
userId: dto.userId,
});
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);
}
@Roles(UserRole.ADMIN)
@Get('stats')
getStats(): Promise<{ totalSubscribers: number }> {
return this.pushService.getStats();
}
@Roles(UserRole.ADMIN, UserRole.CONTRIBUTOR)
@Post('send')
@HttpCode(HttpStatus.OK)
async sendNotification(
@Body() dto: SendNotificationDto,
): Promise<{ sent: number; failed: number }> {
return this.pushService.sendToAll({
title: dto.title,
body: dto.body,
url: dto.url,
tag: 'admin-notification',
});
}
}
+39
View File
@@ -0,0 +1,39 @@
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
export class SubscribeDto {
@IsString()
@IsNotEmpty()
endpoint: string;
@IsString()
@IsNotEmpty()
p256dh: string;
@IsString()
@IsNotEmpty()
auth: string;
@IsOptional()
@IsString()
userId?: string;
}
export class UnsubscribeDto {
@IsString()
@IsNotEmpty()
endpoint: string;
}
export class SendNotificationDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsNotEmpty()
body: string;
@IsOptional()
@IsString()
url?: string;
}
+13
View 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 {}
+185
View File
@@ -0,0 +1,185 @@
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> {
this.logger.log('Subscribing push notification...');
this.logger.debug(`Endpoint: ${dto.endpoint?.substring(0, 50)}...`);
const existing = await this.subscriptionRepo.findOne({
where: { endpoint: dto.endpoint },
});
if (existing) {
this.logger.log('Subscription already exists, updating...');
if (dto.userId && existing.userId !== dto.userId) {
existing.userId = dto.userId;
return this.subscriptionRepo.save(existing);
}
return existing;
}
this.logger.log('Creating new subscription...');
const subscription = this.subscriptionRepo.create({
endpoint: dto.endpoint,
p256dh: dto.p256dh,
auth: dto.auth,
userId: dto.userId ?? null,
});
const saved = await this.subscriptionRepo.save(subscription);
this.logger.log(`Subscription saved with ID: ${saved.id}`);
return saved;
}
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}`,
});
}
async getStats(): Promise<{ totalSubscribers: number }> {
const totalSubscribers = await this.subscriptionRepo.count();
return { totalSubscribers };
}
}
@@ -0,0 +1,166 @@
import { useState } from 'react';
import { usePushStats, useSendPushNotification } from '@/queries/push';
import { Button } from '@/components/ui/button';
import { Bell, Send, Users, AlertCircle, CheckCircle } from 'lucide-react';
export function PushNotificationManager() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [url, setUrl] = useState('');
const [showSuccess, setShowSuccess] = useState(false);
const [result, setResult] = useState<{ sent: number; failed: number } | null>(
null,
);
const { data: stats, isLoading: loadingStats } = usePushStats();
const sendMutation = useSendPushNotification();
const handleSendTest = async () => {
if (!title.trim() || !body.trim()) return;
try {
const result = await sendMutation.mutateAsync({
title: title.trim(),
body: body.trim(),
url: url.trim() || undefined,
});
setResult(result);
setShowSuccess(true);
setTimeout(() => setShowSuccess(false), 5000);
} catch (error) {
console.error('Failed to send notification:', error);
}
};
const isValid = title.trim().length > 0 && body.trim().length > 0;
return (
<div className="border-brutal bg-card p-6">
<div className="flex items-center gap-3 mb-6">
<Bell className="h-6 w-6 text-accent" />
<h2 className="text-2xl font-display uppercase">Push Notifications</h2>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1">
<div className="border-brutal-sm bg-background p-5 h-full">
<div className="flex items-center gap-2 mb-3">
<Users className="h-5 w-5 text-muted-foreground" />
<span className="font-body text-sm uppercase tracking-wider text-muted-foreground">
Subscribers
</span>
</div>
{loadingStats ? (
<div className="text-4xl font-display animate-pulse">...</div>
) : (
<div className="text-5xl font-display">
{stats?.totalSubscribers ?? 0}
</div>
)}
<p className="text-xs font-body text-muted-foreground mt-2">
Active push subscribers
</p>
</div>
</div>
<div className="lg:col-span-2">
<div className="border-brutal-sm bg-background p-5">
<h3 className="font-body text-sm uppercase tracking-wider text-muted-foreground mb-4">
Send Notification
</h3>
<div className="space-y-4">
<div>
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
Title
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter notification title..."
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
maxLength={50}
/>
</div>
<div>
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
Body
</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Enter notification message..."
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none resize-none"
rows={3}
maxLength={150}
/>
</div>
<div>
<label className="block font-body text-xs uppercase tracking-wider text-muted-foreground mb-1">
URL (optional)
</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="/article/slug or /live-blogs/slug"
className="w-full border-2 border-foreground bg-background px-3 py-2 font-body text-sm focus:border-accent focus:outline-none"
/>
</div>
<div className="flex items-center justify-between gap-4 pt-2">
<div className="text-xs font-body text-muted-foreground">
{title.length}/50 chars • {body.length}/150 chars
</div>
<Button
variant="brutalAccent"
onClick={handleSendTest}
disabled={!isValid || sendMutation.isPending}
className="flex items-center gap-2"
>
<Send className="h-4 w-4" />
{sendMutation.isPending ? 'Sending...' : 'Send to All'}
</Button>
</div>
</div>
{showSuccess && result && (
<div className="mt-4 p-3 border-2 border-green-500 bg-green-500/10 flex items-start gap-2">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div>
<p className="font-body text-sm font-medium">
Notification sent!
</p>
<p className="font-body text-xs text-muted-foreground">
Delivered: {result.sent} • Failed: {result.failed}
</p>
</div>
</div>
)}
{sendMutation.isError && (
<div className="mt-4 p-3 border-2 border-red-500 bg-red-500/10 flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-red-500" />
<p className="font-body text-sm">
Failed to send notification. Please try again.
</p>
</div>
)}
</div>
</div>
</div>
<div className="mt-6 p-4 border-2 border-foreground/20 bg-foreground/5">
<p className="font-body text-xs text-muted-foreground">
<strong>Note:</strong> Notifications are sent to all subscribers with
push enabled. Use sparingly to avoid spam. Articles and live blog
updates are automatically pushed when published.
</p>
</div>
</div>
);
}
@@ -6,6 +6,7 @@ import { Link } from '@tanstack/react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { mk } from 'date-fns/locale'; import { mk } from 'date-fns/locale';
import { PushNotificationManager } from '@/components/admin/PushNotificationManager';
export function AdminDashboardComponent() { export function AdminDashboardComponent() {
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
@@ -167,32 +168,42 @@ export function AdminDashboardComponent() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="border-brutal bg-card p-5"> <div className="border-brutal bg-card p-5">
<div className="text-4xl font-display"> <div className="text-4xl font-display">
{liveBlogs.filter(b => b.status === 'live').length || 0} {liveBlogs.filter((b) => b.status === 'live').length || 0}
</div> </div>
<p className="text-xs font-body text-muted-foreground mt-1">Активни live блогови</p> <p className="text-xs font-body text-muted-foreground mt-1">
Активни live блогови
</p>
</div> </div>
<div className="border-brutal bg-card p-5"> <div className="border-brutal bg-card p-5">
<div className="text-4xl font-display"> <div className="text-4xl font-display">
{articles.filter(a => a.status === 'published').length || 0} {articles.filter((a) => a.status === 'published').length || 0}
</div> </div>
<p className="text-xs font-body text-muted-foreground mt-1">Објавени написи</p> <p className="text-xs font-body text-muted-foreground mt-1">
Објавени написи
</p>
</div> </div>
<div className="border-brutal bg-card p-5"> <div className="border-brutal bg-card p-5">
<div className="text-4xl font-display"> <div className="text-4xl font-display">
{liveBlogs.filter(b => b.isPinned).length || 0} {liveBlogs.filter((b) => b.isPinned).length || 0}
</div> </div>
<p className="text-xs font-body text-muted-foreground mt-1">Закачени live блогови</p> <p className="text-xs font-body text-muted-foreground mt-1">
Закачени live блогови
</p>
</div> </div>
<div className="border-brutal bg-card p-5"> <div className="border-brutal bg-card p-5">
<div className="text-4xl font-display"> <div className="text-4xl font-display">
{(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) + {(liveBlogs.reduce((sum, b) => sum + b.viewCount, 0) || 0) +
(articles.reduce((sum, a) => sum + a.views, 0) || 0)} (articles.reduce((sum, a) => sum + a.views, 0) || 0)}
</div> </div>
<p className="text-xs font-body text-muted-foreground mt-1">Вкупни прегледи</p> <p className="text-xs font-body text-muted-foreground mt-1">
Вкупни прегледи
</p>
</div> </div>
</div> </div>
)} )}
{!showArchived && <PushNotificationManager />}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="border-brutal bg-card"> <div className="border-brutal bg-card">
<div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between"> <div className="border-b-2 border-foreground/10 p-4 flex items-center justify-between">
+38
View File
@@ -827,3 +827,41 @@ export async function getUserReaction(
} }
return response.json(); return response.json();
} }
// Push Notification Admin Types
export interface PushStats {
totalSubscribers: number;
}
export interface SendNotificationResult {
sent: number;
failed: number;
}
export interface SendNotificationDto {
title: string;
body: string;
url?: string;
}
// Push Notification Admin API Functions
export async function fetchPushStats(): Promise<PushStats> {
const response = await authFetch(`${API_BASE_URL}/push/stats`);
if (!response.ok) {
throw new Error('Failed to fetch push stats');
}
return response.json();
}
export async function sendPushNotification(
dto: SendNotificationDto,
): Promise<SendNotificationResult> {
const response = await authFetch(`${API_BASE_URL}/push/send`, {
method: 'POST',
body: JSON.stringify(dto),
});
if (!response.ok) {
throw new Error('Failed to send push notification');
}
return response.json();
}
+20
View File
@@ -0,0 +1,20 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as api from '../lib/api';
export function usePushStats() {
return useQuery({
queryKey: ['push', 'stats'],
queryFn: api.fetchPushStats,
});
}
export function useSendPushNotification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.sendPushNotification,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['push', 'stats'] });
},
});
}
+108 -49
View File
@@ -42,7 +42,8 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"typeorm": "^0.3.28" "typeorm": "^0.3.28",
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@@ -54,6 +55,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/web-push": "^3.6.4",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2", "eslint-plugin-prettier": "^5.2.2",
@@ -5249,10 +5251,6 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"backend/node_modules/inherits": {
"version": "2.0.4",
"license": "ISC"
},
"backend/node_modules/ini": { "backend/node_modules/ini": {
"version": "1.3.8", "version": "1.3.8",
"license": "ISC" "license": "ISC"
@@ -6520,13 +6518,6 @@
"node": "*" "node": "*"
} }
}, },
"backend/node_modules/minimist": {
"version": "1.2.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"backend/node_modules/minipass": { "backend/node_modules/minipass": {
"version": "7.1.2", "version": "7.1.2",
"license": "ISC", "license": "ISC",
@@ -7610,10 +7601,6 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"backend/node_modules/safer-buffer": {
"version": "2.1.2",
"license": "MIT"
},
"backend/node_modules/schema-utils": { "backend/node_modules/schema-utils": {
"version": "3.3.0", "version": "3.3.0",
"dev": true, "dev": true,
@@ -14119,16 +14106,6 @@
"version": "2.0.6", "version": "2.0.6",
"license": "MIT" "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": { "cms/cms/node_modules/ast-types": {
"version": "0.16.1", "version": "0.16.1",
"license": "MIT", "license": "MIT",
@@ -14254,10 +14231,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"cms/cms/node_modules/bn.js": {
"version": "4.12.2",
"license": "MIT"
},
"cms/cms/node_modules/boolbase": { "cms/cms/node_modules/boolbase": {
"version": "1.0.0", "version": "1.0.0",
"license": "ISC" "license": "ISC"
@@ -17240,10 +17213,6 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"cms/cms/node_modules/inherits": {
"version": "2.0.4",
"license": "ISC"
},
"cms/cms/node_modules/ini": { "cms/cms/node_modules/ini": {
"version": "1.3.8", "version": "1.3.8",
"license": "ISC" "license": "ISC"
@@ -18716,10 +18685,6 @@
"webpack": "^5.0.0" "webpack": "^5.0.0"
} }
}, },
"cms/cms/node_modules/minimalistic-assert": {
"version": "1.0.1",
"license": "ISC"
},
"cms/cms/node_modules/minimalistic-crypto-utils": { "cms/cms/node_modules/minimalistic-crypto-utils": {
"version": "1.0.1", "version": "1.0.1",
"license": "MIT" "license": "MIT"
@@ -18737,13 +18702,6 @@
"url": "https://github.com/sponsors/isaacs" "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": { "cms/cms/node_modules/minipass": {
"version": "7.1.2", "version": "7.1.2",
"license": "ISC", "license": "ISC",
@@ -21241,10 +21199,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"cms/cms/node_modules/safer-buffer": {
"version": "2.1.2",
"license": "MIT"
},
"cms/cms/node_modules/sanitize-html": { "cms/cms/node_modules/sanitize-html": {
"version": "2.13.0", "version": "2.13.0",
"license": "MIT", "license": "MIT",
@@ -27112,12 +27066,31 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT" "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": { "node_modules/@ungap/structured-clone": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC" "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": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "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" "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": { "node_modules/backend": {
"resolved": "backend", "resolved": "backend",
"link": true "link": true
@@ -27170,6 +27155,12 @@
"node": ">= 18" "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": { "node_modules/buffer-equal-constant-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "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" "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": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -27583,6 +27596,12 @@
], ],
"license": "BSD-3-Clause" "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": { "node_modules/inline-style-parser": {
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -28632,6 +28651,21 @@
], ],
"license": "MIT" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -29024,6 +29058,12 @@
], ],
"license": "MIT" "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": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -29377,6 +29417,25 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+1
View File
@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
+101
View File
@@ -0,0 +1,101 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didn’t register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-d975e299'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.ek3n41mv3u8"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
workbox.registerRoute(/^https?:\/\/.*\/api\/.*/i, new workbox.StaleWhileRevalidate({
"cacheName": "api-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 86400
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
}));
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,93 @@
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 () => {
console.log('handleSubscribe called');
console.log('isSupported:', isSupported);
console.log('isSubscribed:', isSubscribed);
try {
const success = await subscribe();
console.log('subscribe() returned:', success);
if (success) {
setIsVisible(false);
}
} catch (error) {
console.error('handleSubscribe error:', error);
}
};
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>
);
}
+194
View File
@@ -0,0 +1,194 @@
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(() => {
console.log('usePushNotifications - isSupported:', checkPushSupport());
console.log('usePushNotifications - permissionState:', getInitialPermission());
}, []);
useEffect(() => {
if (!isSupported || hasCheckedRef.current) return;
hasCheckedRef.current = true;
console.log('Checking existing push subscription...');
navigator.serviceWorker.ready
.then((registration) => registration.pushManager.getSubscription())
.then((subscription) => {
console.log('Existing subscription:', 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') {
console.log('Push permission denied');
setIsLoading(false);
return false;
}
const publicKey = await getVapidPublicKey();
if (!publicKey) {
console.error('VAPID public key not available');
setIsLoading(false);
return false;
}
console.log('Got VAPID public key, subscribing...');
// Check if service worker is already ready
console.log('Checking service worker status...');
const swController = navigator.serviceWorker.controller;
console.log('Service worker controller:', swController);
// Wait for service worker to be ready with timeout
const registration = await Promise.race([
navigator.serviceWorker.ready,
new Promise<ServiceWorkerRegistration>((_, reject) => {
setTimeout(() => {
reject(new Error('Service worker ready timeout after 10s'));
}, 10000);
}),
]);
console.log('Service worker ready:', registration);
if (!registration.pushManager) {
console.error('PushManager not available in service worker registration');
setIsLoading(false);
return false;
}
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
console.log('Creating new push subscription...');
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
console.log('Push subscription created');
} else {
console.log('Existing push subscription found');
}
const subJson = subscription.toJSON();
console.log('Subscription JSON:', subJson);
if (
!subJson.endpoint ||
!subJson.keys?.p256dh ||
!subJson.keys?.auth
) {
console.error('Invalid subscription data:', subJson);
setIsLoading(false);
return false;
}
const success = await subscribeToPush(subJson as PushSubscriptionData);
console.log('Subscribe to push result:', success);
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,
};
}
+101
View File
@@ -0,0 +1,101 @@
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 {
console.log('Sending subscription to server:', {
endpoint: subscription.endpoint,
hasKeys: !!subscription.keys,
});
const payload = {
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
};
console.log('Payload:', payload);
console.log('API URL:', `${API_BASE_URL}/push/subscribe`);
const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
console.log('Subscribe response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('Subscribe failed:', errorText);
return false;
}
return true;
} 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;
}
+19 -1
View File
@@ -5,12 +5,30 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { router } from './routes' import { router } from './routes'
import { AuthProvider } from './contexts/AuthContext' import { AuthProvider } from './contexts/AuthContext'
import { initializeTheme } from './lib/theme' import { initializeTheme } from './lib/theme'
import 'virtual:pwa-register' import { registerSW } from 'virtual:pwa-register'
initializeTheme() initializeTheme()
const queryClient = new QueryClient() const queryClient = new QueryClient()
// Register service worker
const updateSW = registerSW({
onNeedRefresh() {
if (confirm('New content available. Reload?')) {
updateSW(true)
}
},
onOfflineReady() {
console.log('App ready to work offline')
},
onRegistered(registration) {
console.log('SW Registered:', registration?.scope)
},
onRegisterError(error) {
console.error('SW registration error:', error)
},
})
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
+3
View File
@@ -16,6 +16,7 @@ import { Header } from './components/layout/Header'
import { HeroArticle } from './components/home/HeroArticle' import { HeroArticle } from './components/home/HeroArticle'
import { PinnedLiveBlogsSidebar } from './components/home/PinnedLiveBlogsSidebar' import { PinnedLiveBlogsSidebar } from './components/home/PinnedLiveBlogsSidebar'
import { LatestArticlesGrid } from './components/home/LatestArticlesGrid' import { LatestArticlesGrid } from './components/home/LatestArticlesGrid'
import { NotificationBanner } from './components/ui/notification-banner'
import { Button } from './components/ui/button' import { Button } from './components/ui/button'
import { Zap, Search, Users } from 'lucide-react' import { Zap, Search, Users } from 'lucide-react'
import './styles.css' import './styles.css'
@@ -38,6 +39,8 @@ const rootRoute = createRootRoute({
<Outlet /> <Outlet />
</main> </main>
<NotificationBanner />
<footer className="border-t-4 border-foreground bg-foreground text-background"> <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="container mx-auto max-w-7xl px-4 py-12">
<div className="grid md:grid-cols-3 gap-8"> <div className="grid md:grid-cols-3 gap-8">
+3 -2
View File
@@ -32,9 +32,10 @@ self.addEventListener('push', (event: PushEvent) => {
const title = data.title ?? 'Placebo.mk' const title = data.title ?? 'Placebo.mk'
const options = { const options = {
body: data.body ?? 'New update available', body: data.body ?? 'New update available',
icon: '/icons/icon-192.png', icon: data.icon ?? '/icons/icon-192.svg',
badge: '/icons/badge-72.png', badge: data.badge ?? '/icons/badge-72.svg',
data: data.url ?? '/', data: data.url ?? '/',
tag: data.tag,
} }
event.waitUntil(self.registration.showNotification(title, options)) event.waitUntil(self.registration.showNotification(title, options))
+1
View File
@@ -44,6 +44,7 @@ export default defineConfig({
}, },
devOptions: { devOptions: {
enabled: true, enabled: true,
type: 'module',
}, },
}), }),
], ],