Notifications, div fixes, kekse für last location
This commit is contained in:
264
package-lock.json
generated
264
package-lock.json
generated
@@ -15,10 +15,12 @@
|
||||
"enhanced-postgres-mcp-server": "^1.0.1",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.11.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
@@ -714,6 +716,18 @@
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
||||
},
|
||||
"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/ast-types": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||
@@ -853,6 +867,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
@@ -906,6 +926,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -1253,6 +1279,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -1978,6 +2013,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"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-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
@@ -2239,6 +2283,27 @@
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
|
||||
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.0",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -2360,6 +2425,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -2371,6 +2442,15 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"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/minipass": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
||||
@@ -2451,6 +2531,15 @@
|
||||
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
@@ -3981,6 +4070,70 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"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/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"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/web-push/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
@@ -4681,6 +4834,17 @@
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
||||
},
|
||||
"asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"requires": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"ast-types": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||
@@ -4765,6 +4929,11 @@
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true
|
||||
},
|
||||
"bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="
|
||||
},
|
||||
"body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
@@ -4807,6 +4976,11 @@
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="
|
||||
},
|
||||
"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",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
||||
},
|
||||
"bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -5037,6 +5211,14 @@
|
||||
"gopd": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"requires": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -5520,6 +5702,11 @@
|
||||
"function-bind": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"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=="
|
||||
},
|
||||
"http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
@@ -5705,6 +5892,25 @@
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
|
||||
},
|
||||
"jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"requires": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"jws": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
|
||||
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
|
||||
"requires": {
|
||||
"jwa": "^2.0.0",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -5783,6 +5989,11 @@
|
||||
"mime-db": "1.52.0"
|
||||
}
|
||||
},
|
||||
"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=="
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@@ -5791,6 +6002,11 @@
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="
|
||||
},
|
||||
"minipass": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
||||
@@ -5845,6 +6061,11 @@
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
|
||||
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="
|
||||
},
|
||||
"node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg=="
|
||||
},
|
||||
"node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
@@ -6885,6 +7106,47 @@
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="
|
||||
},
|
||||
"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==",
|
||||
"requires": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"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=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"requires": {
|
||||
"ms": "^2.1.3"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"requires": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
"enhanced-postgres-mcp-server": "^1.0.1",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.11.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
|
||||
@@ -1374,3 +1374,9 @@ body {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.last-location-info small {
|
||||
color: #b3e5fc;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,68 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SPEEDRUN ARENA - Admin Dashboard</title>
|
||||
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Ninja Cross">
|
||||
<link rel="apple-touch-icon" href="/pictures/favicon.ico">
|
||||
<script src="https://unpkg.com/@supabase/supabase-js@2"></script>
|
||||
<!-- QR Code Scanner Library -->
|
||||
<script src="https://unpkg.com/jsqr@1.4.0/dist/jsQR.js"></script>
|
||||
<link rel="stylesheet" href="/css/dashboard.css">
|
||||
|
||||
<!-- Notification Permission Script -->
|
||||
<script>
|
||||
// Register Service Worker for iPhone Notifications
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(function(registration) {
|
||||
console.log('✅ Service Worker registered:', registration);
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.log('❌ Service Worker registration failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Request notification permission on page load
|
||||
if ('Notification' in window) {
|
||||
if (Notification.permission === 'default') {
|
||||
Notification.requestPermission().then(function(permission) {
|
||||
if (permission === 'granted') {
|
||||
console.log('✅ Notification permission granted');
|
||||
// Subscribe to push notifications
|
||||
subscribeToPush();
|
||||
} else {
|
||||
console.log('❌ Notification permission denied');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to push notifications
|
||||
async function subscribeToPush() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: 'BEl62iUYgUivxIkv69yViEuiBIa40HI6F2B5L4h7Q8Y'
|
||||
});
|
||||
|
||||
// Send subscription to server
|
||||
await fetch('/api/v1/public/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(subscription)
|
||||
});
|
||||
|
||||
console.log('✅ Push subscription successful');
|
||||
} catch (error) {
|
||||
console.error('❌ Push subscription failed:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
@@ -34,6 +92,7 @@
|
||||
<div class="welcome-card">
|
||||
<h2>Dein Dashboard 🥷</h2>
|
||||
<p>Willkommen in Deinem Dashboard-Panel! Deine übersichtliche Übersicht aller deiner Läufe.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
@@ -252,6 +311,6 @@
|
||||
</footer>
|
||||
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/dashboard.js"></script>
|
||||
<script src="/js/dashboard.js?v=1.1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>Timer Leaderboard</title>
|
||||
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
|
||||
<script src="/js/page-tracking.js"></script>
|
||||
<script src="/js/cookie-utils.js"></script>
|
||||
<link rel="stylesheet" href="/css/leaderboard.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
105
public/js/cookie-utils.js
Normal file
105
public/js/cookie-utils.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// Cookie Utility Functions
|
||||
class CookieManager {
|
||||
// Set a cookie
|
||||
static setCookie(name, value, days = 30) {
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`;
|
||||
}
|
||||
|
||||
// Get a cookie
|
||||
static getCookie(name) {
|
||||
const nameEQ = name + "=";
|
||||
const ca = document.cookie.split(';');
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i];
|
||||
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
|
||||
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Delete a cookie
|
||||
static deleteCookie(name) {
|
||||
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`;
|
||||
}
|
||||
|
||||
// Check if cookies are enabled
|
||||
static areCookiesEnabled() {
|
||||
try {
|
||||
this.setCookie('test', 'test');
|
||||
const enabled = this.getCookie('test') === 'test';
|
||||
this.deleteCookie('test');
|
||||
return enabled;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Location-specific cookie functions
|
||||
class LocationCookieManager {
|
||||
static COOKIE_NAME = 'ninjacross_last_location';
|
||||
static COOKIE_EXPIRY_DAYS = 90; // 3 months
|
||||
|
||||
// Save last selected location
|
||||
static saveLastLocation(locationId, locationName) {
|
||||
if (!locationId || !locationName) return;
|
||||
|
||||
const locationData = {
|
||||
id: locationId,
|
||||
name: locationName,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
CookieManager.setCookie(
|
||||
this.COOKIE_NAME,
|
||||
JSON.stringify(locationData),
|
||||
this.COOKIE_EXPIRY_DAYS
|
||||
);
|
||||
console.log('✅ Location saved to cookie:', locationName);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to save location to cookie:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get last selected location
|
||||
static getLastLocation() {
|
||||
try {
|
||||
const cookieValue = CookieManager.getCookie(this.COOKIE_NAME);
|
||||
if (!cookieValue) return null;
|
||||
|
||||
const locationData = JSON.parse(cookieValue);
|
||||
|
||||
// Check if cookie is not too old (optional: 30 days max)
|
||||
const cookieDate = new Date(locationData.timestamp);
|
||||
const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds
|
||||
if (Date.now() - cookieDate.getTime() > maxAge) {
|
||||
this.clearLastLocation();
|
||||
return null;
|
||||
}
|
||||
|
||||
return locationData;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to parse location cookie:', error);
|
||||
this.clearLastLocation();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear last location
|
||||
static clearLastLocation() {
|
||||
CookieManager.deleteCookie(this.COOKIE_NAME);
|
||||
console.log('🗑️ Location cookie cleared');
|
||||
}
|
||||
|
||||
// Check if location cookie exists
|
||||
static hasLastLocation() {
|
||||
return this.getLastLocation() !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
window.CookieManager = CookieManager;
|
||||
window.LocationCookieManager = LocationCookieManager;
|
||||
@@ -132,12 +132,13 @@ async function checkLinkStatusAndLoadTimes() {
|
||||
|
||||
try {
|
||||
// Check if user has a linked player
|
||||
const response = await fetch(`/api/v1/public/user-player/${currentUser.id}`);
|
||||
const response = await fetch(`/api/v1/public/user-player/${currentUser.id}?t=${Date.now()}`);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
// User is linked, load times
|
||||
await loadUserTimesSection(result.data);
|
||||
|
||||
} else {
|
||||
// User is not linked
|
||||
showTimesNotLinked();
|
||||
@@ -362,7 +363,7 @@ async function loadUserTimesSection(playerData) {
|
||||
showTimesLoading();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/public/user-times/${currentUser.id}`);
|
||||
const response = await fetch(`/api/v1/public/user-times/${currentUser.id}?t=${Date.now()}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -608,14 +609,15 @@ async function loadPlayerAchievements() {
|
||||
document.getElementById('achievementCategories').style.display = 'none';
|
||||
document.getElementById('achievementsNotAvailable').style.display = 'none';
|
||||
|
||||
// Load player achievements
|
||||
const response = await fetch(`/api/achievements/player/${currentPlayerId}`);
|
||||
// Load player achievements (includes all achievements with player status)
|
||||
const response = await fetch(`/api/achievements/player/${currentPlayerId}?t=${Date.now()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load achievements');
|
||||
throw new Error('Failed to load player achievements');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
playerAchievements = result.data;
|
||||
window.allAchievements = result.data;
|
||||
playerAchievements = result.data.filter(achievement => achievement.is_completed);
|
||||
|
||||
// Load achievement statistics
|
||||
await loadAchievementStats();
|
||||
@@ -639,7 +641,7 @@ async function loadPlayerAchievements() {
|
||||
// Load achievement statistics
|
||||
async function loadAchievementStats() {
|
||||
try {
|
||||
const response = await fetch(`/api/achievements/player/${currentPlayerId}/stats`);
|
||||
const response = await fetch(`/api/achievements/player/${currentPlayerId}/stats?t=${Date.now()}`);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
window.achievementStats = result.data;
|
||||
@@ -665,7 +667,7 @@ function displayAchievementStats() {
|
||||
function displayAchievements() {
|
||||
const achievementsGrid = document.getElementById('achievementsGrid');
|
||||
|
||||
if (playerAchievements.length === 0) {
|
||||
if (!window.allAchievements || window.allAchievements.length === 0) {
|
||||
achievementsGrid.innerHTML = `
|
||||
<div class="no-achievements">
|
||||
<div class="no-achievements-icon">🏆</div>
|
||||
@@ -677,9 +679,9 @@ function displayAchievements() {
|
||||
}
|
||||
|
||||
// Filter achievements by category
|
||||
let filteredAchievements = playerAchievements;
|
||||
let filteredAchievements = window.allAchievements;
|
||||
if (currentAchievementCategory !== 'all') {
|
||||
filteredAchievements = playerAchievements.filter(achievement =>
|
||||
filteredAchievements = window.allAchievements.filter(achievement =>
|
||||
achievement.category === currentAchievementCategory
|
||||
);
|
||||
}
|
||||
@@ -690,6 +692,11 @@ function displayAchievements() {
|
||||
const progress = achievement.progress || 0;
|
||||
const earnedAt = achievement.earned_at;
|
||||
|
||||
// Debug logging
|
||||
if (achievement.name === 'Tageskönig') {
|
||||
console.log('Tageskönig Debug:', { isCompleted, progress, earnedAt });
|
||||
}
|
||||
|
||||
let progressText = '';
|
||||
if (isCompleted) {
|
||||
progressText = earnedAt ?
|
||||
@@ -780,7 +787,7 @@ async function checkPlayerAchievements() {
|
||||
if (!currentPlayerId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/achievements/check/${currentPlayerId}`, {
|
||||
const response = await fetch(`/api/achievements/check/${currentPlayerId}?t=${Date.now()}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
@@ -831,6 +838,109 @@ function initializeAchievements(playerId) {
|
||||
loadPlayerAchievements();
|
||||
}
|
||||
|
||||
// Web Notification Functions
|
||||
function showWebNotification(title, message, icon = '🏆') {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
const notification = new Notification(title, {
|
||||
body: message,
|
||||
icon: '/pictures/favicon.ico',
|
||||
badge: '/pictures/favicon.ico',
|
||||
tag: 'ninjacross-achievement',
|
||||
requireInteraction: true
|
||||
});
|
||||
|
||||
// Auto-close after 10 seconds
|
||||
setTimeout(() => {
|
||||
notification.close();
|
||||
}, 10000);
|
||||
|
||||
// Handle click
|
||||
notification.onclick = function() {
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for best time achievements and show notifications
|
||||
async function checkBestTimeNotifications() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/public/best-times');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
const { daily, weekly, monthly } = result.data;
|
||||
|
||||
// Check if current player has best times
|
||||
if (currentPlayerId) {
|
||||
if (daily && daily.player_id === currentPlayerId) {
|
||||
showWebNotification(
|
||||
'🏆 Tageskönig!',
|
||||
`Glückwunsch! Du hast die beste Zeit des Tages mit ${daily.best_time} erreicht!`,
|
||||
'👑'
|
||||
);
|
||||
}
|
||||
|
||||
if (weekly && weekly.player_id === currentPlayerId) {
|
||||
showWebNotification(
|
||||
'🏆 Wochenchampion!',
|
||||
`Fantastisch! Du bist der Wochenchampion mit ${weekly.best_time}!`,
|
||||
'🏆'
|
||||
);
|
||||
}
|
||||
|
||||
if (monthly && monthly.player_id === currentPlayerId) {
|
||||
showWebNotification(
|
||||
'🏆 Monatsmeister!',
|
||||
`Unglaublich! Du bist der Monatsmeister mit ${monthly.best_time}!`,
|
||||
'🥇'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking best time notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new achievements and show notifications
|
||||
async function checkAchievementNotifications() {
|
||||
try {
|
||||
if (!currentPlayerId) return;
|
||||
|
||||
const response = await fetch(`/api/achievements/player/${currentPlayerId}?t=${Date.now()}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
const newAchievements = result.data.filter(achievement => {
|
||||
// Check if achievement was earned in the last 5 minutes
|
||||
const earnedAt = new Date(achievement.earned_at);
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
||||
return earnedAt > fiveMinutesAgo;
|
||||
});
|
||||
|
||||
if (newAchievements.length > 0) {
|
||||
newAchievements.forEach(achievement => {
|
||||
showWebNotification(
|
||||
`🏆 ${achievement.name}`,
|
||||
achievement.description,
|
||||
achievement.icon || '🏆'
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking achievement notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Periodic check for notifications (every 30 seconds)
|
||||
setInterval(() => {
|
||||
checkBestTimeNotifications();
|
||||
checkAchievementNotifications();
|
||||
}, 30000);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add cookie settings button functionality
|
||||
const cookieSettingsBtn = document.getElementById('cookie-settings-footer');
|
||||
|
||||
@@ -10,6 +10,58 @@ const socket = io();
|
||||
|
||||
// Global variable to store locations with coordinates
|
||||
let locationsData = [];
|
||||
let lastSelectedLocation = null;
|
||||
|
||||
// Cookie Functions (inline implementation)
|
||||
function setCookie(name, value, days = 30) {
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`;
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
const nameEQ = name + "=";
|
||||
const ca = document.cookie.split(';');
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i];
|
||||
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
|
||||
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadLastSelectedLocation() {
|
||||
try {
|
||||
const cookieValue = getCookie('ninjacross_last_location');
|
||||
if (cookieValue) {
|
||||
const lastLocation = JSON.parse(cookieValue);
|
||||
lastSelectedLocation = lastLocation;
|
||||
console.log('📍 Last selected location loaded:', lastLocation.name);
|
||||
return lastLocation;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading last location:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveLocationSelection(locationId, locationName) {
|
||||
try {
|
||||
// Remove emoji from location name for storage
|
||||
const cleanName = locationName.replace(/^📍\s*/, '');
|
||||
const locationData = {
|
||||
id: locationId,
|
||||
name: cleanName,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
setCookie('ninjacross_last_location', JSON.stringify(locationData), 90);
|
||||
lastSelectedLocation = { id: locationId, name: cleanName };
|
||||
console.log('💾 Location saved to cookie:', cleanName);
|
||||
} catch (error) {
|
||||
console.error('Error saving location:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket Event Handlers
|
||||
socket.on('connect', () => {
|
||||
@@ -129,6 +181,23 @@ async function loadLocations() {
|
||||
locationSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Load and set last selected location
|
||||
const lastLocation = loadLastSelectedLocation();
|
||||
if (lastLocation) {
|
||||
// Find the option that matches the last location name
|
||||
const matchingOption = Array.from(locationSelect.options).find(option =>
|
||||
option.textContent === `📍 ${lastLocation.name}` || option.value === lastLocation.name
|
||||
);
|
||||
if (matchingOption) {
|
||||
locationSelect.value = matchingOption.value;
|
||||
console.log('📍 Last selected location restored:', lastLocation.name);
|
||||
// Update the current selection display
|
||||
updateCurrentSelection();
|
||||
// Load data for the restored location
|
||||
loadData();
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading locations:', error);
|
||||
}
|
||||
@@ -489,7 +558,15 @@ function updateLeaderboard(data) {
|
||||
// Event Listeners Setup
|
||||
function setupEventListeners() {
|
||||
// Location select event listener
|
||||
document.getElementById('locationSelect').addEventListener('change', loadData);
|
||||
document.getElementById('locationSelect').addEventListener('change', function() {
|
||||
// Save location selection to cookie
|
||||
const selectedOption = this.options[this.selectedIndex];
|
||||
if (selectedOption.value) {
|
||||
saveLocationSelection(selectedOption.value, selectedOption.textContent);
|
||||
}
|
||||
// Load data
|
||||
loadData();
|
||||
});
|
||||
|
||||
// Time tab event listeners
|
||||
document.querySelectorAll('.time-tab').forEach(tab => {
|
||||
|
||||
43
public/manifest.json
Normal file
43
public/manifest.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "Ninja Cross Parkour",
|
||||
"short_name": "NinjaCross",
|
||||
"description": "Dein persönliches Dashboard für die Ninja Cross Arena",
|
||||
"start_url": "/index.html",
|
||||
"display": "standalone",
|
||||
"background_color": "#667eea",
|
||||
"theme_color": "#764ba2",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pictures/favicon.ico",
|
||||
"sizes": "192x192",
|
||||
"type": "image/x-icon",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/pictures/favicon.ico",
|
||||
"sizes": "512x512",
|
||||
"type": "image/x-icon",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["sports", "fitness", "entertainment"],
|
||||
"lang": "de",
|
||||
"dir": "ltr",
|
||||
"scope": "/",
|
||||
"prefer_related_applications": false,
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Dashboard",
|
||||
"short_name": "Dashboard",
|
||||
"description": "Öffne dein Dashboard",
|
||||
"url": "/index.html",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pictures/favicon.ico",
|
||||
"sizes": "192x192"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
94
public/sw.js
Normal file
94
public/sw.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Service Worker für iPhone Notifications
|
||||
const CACHE_NAME = 'ninjacross-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/css/leaderboard.css',
|
||||
'/js/leaderboard.js',
|
||||
'/pictures/favicon.ico'
|
||||
];
|
||||
|
||||
// Install event
|
||||
self.addEventListener('install', function(event) {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(function(cache) {
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event
|
||||
self.addEventListener('fetch', function(event) {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(function(response) {
|
||||
// Return cached version or fetch from network
|
||||
return response || fetch(event.request);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// Push event (für iPhone Notifications)
|
||||
self.addEventListener('push', function(event) {
|
||||
console.log('Push received:', event);
|
||||
|
||||
const options = {
|
||||
body: 'Du hast eine neue Notification!',
|
||||
icon: '/pictures/favicon.ico',
|
||||
badge: '/pictures/favicon.ico',
|
||||
vibrate: [100, 50, 100],
|
||||
data: {
|
||||
dateOfArrival: Date.now(),
|
||||
primaryKey: 1
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
action: 'explore',
|
||||
title: 'Dashboard öffnen',
|
||||
icon: '/pictures/favicon.ico'
|
||||
},
|
||||
{
|
||||
action: 'close',
|
||||
title: 'Schließen',
|
||||
icon: '/pictures/favicon.ico'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (event.data) {
|
||||
const data = event.data.json();
|
||||
options.body = data.body || options.body;
|
||||
options.title = data.title || 'Ninja Cross';
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification('Ninja Cross', options)
|
||||
);
|
||||
});
|
||||
|
||||
// Notification click event
|
||||
self.addEventListener('notificationclick', function(event) {
|
||||
console.log('Notification clicked:', event);
|
||||
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'explore') {
|
||||
event.waitUntil(
|
||||
clients.openWindow('/dashboard.html')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Background sync (für offline functionality)
|
||||
self.addEventListener('sync', function(event) {
|
||||
if (event.tag === 'background-sync') {
|
||||
event.waitUntil(doBackgroundSync());
|
||||
}
|
||||
});
|
||||
|
||||
async function doBackgroundSync() {
|
||||
// Sync data when back online
|
||||
console.log('Background sync triggered');
|
||||
}
|
||||
299
routes/api.js
299
routes/api.js
@@ -65,6 +65,11 @@ function convertTimeToSeconds(timeStr) {
|
||||
function convertIntervalToSeconds(interval) {
|
||||
if (!interval) return 0;
|
||||
|
||||
// Handle PostgreSQL interval object format
|
||||
if (typeof interval === 'object' && interval.seconds !== undefined) {
|
||||
return parseFloat(interval.seconds);
|
||||
}
|
||||
|
||||
// PostgreSQL interval format: "HH:MM:SS" or "MM:SS" or just seconds
|
||||
if (typeof interval === 'string') {
|
||||
const parts = interval.split(':');
|
||||
@@ -853,30 +858,27 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => {
|
||||
const thresholdSeconds = convertIntervalToSeconds(location.time_threshold);
|
||||
|
||||
if (recordedTimeSeconds < thresholdSeconds) {
|
||||
// Convert threshold to readable format
|
||||
const thresholdMinutes = Math.floor(thresholdSeconds / 60);
|
||||
const thresholdSecs = Math.floor(thresholdSeconds % 60);
|
||||
const thresholdMs = Math.floor((thresholdSeconds % 1) * 1000);
|
||||
const thresholdDisplay = thresholdMinutes > 0
|
||||
? `${thresholdMinutes}:${thresholdSecs.toString().padStart(2, '0')}.${thresholdMs.toString().padStart(3, '0')}`
|
||||
: `${thresholdSecs}.${thresholdMs.toString().padStart(3, '0')}`;
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Zeit ${recorded_time} liegt unter dem Schwellenwert von ${location.time_threshold} für diesen Standort`,
|
||||
message: `Zeit ${recorded_time} liegt unter dem Schwellenwert von ${thresholdDisplay} für diesen Standort`,
|
||||
data: {
|
||||
recorded_time: recorded_time,
|
||||
threshold: location.time_threshold,
|
||||
threshold_display: thresholdDisplay,
|
||||
location_name: location_name
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prüfen ob Zeit bereits existiert (optional - kann entfernt werden)
|
||||
const existingTime = await pool.query(
|
||||
'SELECT id FROM times WHERE player_id = $1 AND location_id = $2 AND recorded_time = $3',
|
||||
[player_id, location_id, recorded_time]
|
||||
);
|
||||
|
||||
if (existingTime.rows.length > 0) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: 'Zeit existiert bereits in der Datenbank'
|
||||
});
|
||||
}
|
||||
|
||||
// Zeit in Datenbank einfügen
|
||||
const result = await pool.query(
|
||||
@@ -2134,6 +2136,38 @@ router.post('/v1/admin/runs', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
const { player_id, location_id, time_seconds } = req.body;
|
||||
|
||||
// Prüfen ob Location existiert und Threshold abrufen
|
||||
const locationResult = await pool.query(
|
||||
'SELECT id, name, time_threshold FROM locations WHERE id = $1',
|
||||
[location_id]
|
||||
);
|
||||
|
||||
if (locationResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Standort nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
const location = locationResult.rows[0];
|
||||
|
||||
// Prüfen ob die Zeit über dem Schwellenwert liegt
|
||||
if (location.time_threshold) {
|
||||
const thresholdSeconds = convertIntervalToSeconds(location.time_threshold);
|
||||
|
||||
if (time_seconds < thresholdSeconds) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Zeit ${time_seconds}s liegt unter dem Schwellenwert von ${location.time_threshold} für diesen Standort`,
|
||||
data: {
|
||||
recorded_time: `${time_seconds}s`,
|
||||
threshold: location.time_threshold,
|
||||
location_name: location.name
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Zeit in INTERVAL konvertieren
|
||||
const timeInterval = `${time_seconds} seconds`;
|
||||
|
||||
@@ -2300,6 +2334,198 @@ router.put('/v1/admin/adminusers/:id', requireAdminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== BEST TIMES ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/public/best-times:
|
||||
* get:
|
||||
* summary: Beste Zeiten abrufen
|
||||
* description: Ruft die besten Zeiten des Tages, der Woche und des Monats ab
|
||||
* tags: [Times]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Beste Zeiten erfolgreich abgerufen
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* daily:
|
||||
* type: object
|
||||
* properties:
|
||||
* player_id:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* player_name:
|
||||
* type: string
|
||||
* best_time:
|
||||
* type: string
|
||||
* format: time
|
||||
* weekly:
|
||||
* type: object
|
||||
* properties:
|
||||
* player_id:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* player_name:
|
||||
* type: string
|
||||
* best_time:
|
||||
* type: string
|
||||
* format: time
|
||||
* monthly:
|
||||
* type: object
|
||||
* properties:
|
||||
* player_id:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* player_name:
|
||||
* type: string
|
||||
* best_time:
|
||||
* type: string
|
||||
* format: time
|
||||
* 500:
|
||||
* description: Server-Fehler
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
// Get best times (daily, weekly, monthly)
|
||||
router.get('/v1/public/best-times', async (req, res) => {
|
||||
try {
|
||||
const currentDate = new Date();
|
||||
const today = currentDate.toISOString().split('T')[0];
|
||||
const weekStart = new Date(currentDate.setDate(currentDate.getDate() - currentDate.getDay()));
|
||||
const monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1);
|
||||
|
||||
// Get daily best
|
||||
const dailyResult = await pool.query(`
|
||||
WITH daily_best AS (
|
||||
SELECT
|
||||
t.player_id,
|
||||
MIN(t.recorded_time) as best_time,
|
||||
CONCAT(p.firstname, ' ', p.lastname) as player_name
|
||||
FROM times t
|
||||
JOIN players p ON t.player_id = p.id
|
||||
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $1
|
||||
GROUP BY t.player_id, p.firstname, p.lastname
|
||||
)
|
||||
SELECT
|
||||
player_id,
|
||||
player_name,
|
||||
best_time
|
||||
FROM daily_best
|
||||
WHERE best_time = (SELECT MIN(best_time) FROM daily_best)
|
||||
LIMIT 1
|
||||
`, [today]);
|
||||
|
||||
// Get weekly best
|
||||
const weeklyResult = await pool.query(`
|
||||
WITH weekly_best AS (
|
||||
SELECT
|
||||
t.player_id,
|
||||
MIN(t.recorded_time) as best_time,
|
||||
CONCAT(p.firstname, ' ', p.lastname) as player_name
|
||||
FROM times t
|
||||
JOIN players p ON t.player_id = p.id
|
||||
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1
|
||||
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2
|
||||
GROUP BY t.player_id, p.firstname, p.lastname
|
||||
)
|
||||
SELECT
|
||||
player_id,
|
||||
player_name,
|
||||
best_time
|
||||
FROM weekly_best
|
||||
WHERE best_time = (SELECT MIN(best_time) FROM weekly_best)
|
||||
LIMIT 1
|
||||
`, [weekStart.toISOString().split('T')[0], today]);
|
||||
|
||||
// Get monthly best
|
||||
const monthlyResult = await pool.query(`
|
||||
WITH monthly_best AS (
|
||||
SELECT
|
||||
t.player_id,
|
||||
MIN(t.recorded_time) as best_time,
|
||||
CONCAT(p.firstname, ' ', p.lastname) as player_name
|
||||
FROM times t
|
||||
JOIN players p ON t.player_id = p.id
|
||||
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1
|
||||
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2
|
||||
GROUP BY t.player_id, p.firstname, p.lastname
|
||||
)
|
||||
SELECT
|
||||
player_id,
|
||||
player_name,
|
||||
best_time
|
||||
FROM monthly_best
|
||||
WHERE best_time = (SELECT MIN(best_time) FROM monthly_best)
|
||||
LIMIT 1
|
||||
`, [monthStart.toISOString().split('T')[0], today]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
daily: dailyResult.rows[0] || null,
|
||||
weekly: weeklyResult.rows[0] || null,
|
||||
monthly: monthlyResult.rows[0] || null
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching best times:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Abrufen der Bestzeiten',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== PUSH NOTIFICATION ENDPOINTS ====================
|
||||
|
||||
// Subscribe to push notifications
|
||||
router.post('/v1/public/subscribe', async (req, res) => {
|
||||
try {
|
||||
const { endpoint, keys } = req.body;
|
||||
|
||||
// Store subscription in database
|
||||
await pool.query(`
|
||||
INSERT INTO player_subscriptions (player_id, endpoint, p256dh, auth, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (player_id)
|
||||
DO UPDATE SET
|
||||
endpoint = EXCLUDED.endpoint,
|
||||
p256dh = EXCLUDED.p256dh,
|
||||
auth = EXCLUDED.auth,
|
||||
updated_at = NOW()
|
||||
`, [
|
||||
req.session.userId || 'anonymous',
|
||||
endpoint,
|
||||
keys.p256dh,
|
||||
keys.auth
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Push subscription erfolgreich gespeichert'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error storing push subscription:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Fehler beim Speichern der Push Subscription',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== ACHIEVEMENT ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
@@ -2396,6 +2622,13 @@ router.get('/achievements', async (req, res) => {
|
||||
// Get player achievements
|
||||
router.get('/achievements/player/:playerId', async (req, res) => {
|
||||
try {
|
||||
// Set no-cache headers
|
||||
res.set({
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
});
|
||||
|
||||
const { playerId } = req.params;
|
||||
|
||||
const result = await pool.query(`
|
||||
@@ -2406,8 +2639,8 @@ router.get('/achievements/player/:playerId', async (req, res) => {
|
||||
a.category,
|
||||
a.icon,
|
||||
a.points,
|
||||
pa.progress,
|
||||
pa.is_completed,
|
||||
COALESCE(pa.progress, 0) as progress,
|
||||
COALESCE(pa.is_completed, false) as is_completed,
|
||||
pa.earned_at
|
||||
FROM achievements a
|
||||
LEFT JOIN player_achievements pa ON a.id = pa.achievement_id AND pa.player_id = $1
|
||||
@@ -2434,11 +2667,25 @@ router.get('/achievements/player/:playerId', async (req, res) => {
|
||||
// Get player achievement statistics
|
||||
router.get('/achievements/player/:playerId/stats', async (req, res) => {
|
||||
try {
|
||||
// Set no-cache headers
|
||||
res.set({
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
});
|
||||
|
||||
const { playerId } = req.params;
|
||||
|
||||
const result = await pool.query(`
|
||||
// Get total available achievements
|
||||
const totalResult = await pool.query(`
|
||||
SELECT COUNT(*) as total_achievements
|
||||
FROM achievements
|
||||
WHERE is_active = true
|
||||
`);
|
||||
|
||||
// Get player's earned achievements
|
||||
const playerResult = await pool.query(`
|
||||
SELECT
|
||||
COUNT(pa.id) as total_achievements,
|
||||
COUNT(CASE WHEN pa.is_completed = true THEN 1 END) as completed_achievements,
|
||||
SUM(CASE WHEN pa.is_completed = true THEN a.points ELSE 0 END) as total_points,
|
||||
COUNT(CASE WHEN pa.is_completed = true AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE THEN 1 END) as achievements_today
|
||||
@@ -2447,17 +2694,21 @@ router.get('/achievements/player/:playerId/stats', async (req, res) => {
|
||||
WHERE a.is_active = true
|
||||
`, [playerId]);
|
||||
|
||||
const stats = result.rows[0];
|
||||
const totalStats = totalResult.rows[0];
|
||||
const playerStats = playerResult.rows[0];
|
||||
|
||||
const totalAchievements = parseInt(totalStats.total_achievements);
|
||||
const completedAchievements = parseInt(playerStats.completed_achievements) || 0;
|
||||
const completionPercentage = totalAchievements > 0 ? Math.round((completedAchievements / totalAchievements) * 100) : 0;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
total_achievements: parseInt(stats.total_achievements),
|
||||
completed_achievements: parseInt(stats.completed_achievements),
|
||||
total_points: parseInt(stats.total_points) || 0,
|
||||
achievements_today: parseInt(stats.achievements_today),
|
||||
completion_percentage: stats.total_achievements > 0 ?
|
||||
Math.round((stats.completed_achievements / stats.total_achievements) * 100) : 0
|
||||
total_achievements: totalAchievements,
|
||||
completed_achievements: completedAchievements,
|
||||
total_points: parseInt(playerStats.total_points) || 0,
|
||||
achievements_today: parseInt(playerStats.achievements_today) || 0,
|
||||
completion_percentage: completionPercentage
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
141
scripts/best_time_notifications.js
Normal file
141
scripts/best_time_notifications.js
Normal file
@@ -0,0 +1,141 @@
|
||||
const { Pool } = require('pg');
|
||||
const cron = require('node-cron');
|
||||
|
||||
// Database connection
|
||||
const pool = new Pool({
|
||||
user: 'postgres',
|
||||
host: 'localhost',
|
||||
database: 'ninjacross',
|
||||
password: 'postgres',
|
||||
port: 5432,
|
||||
});
|
||||
|
||||
async function checkAndNotifyBestTimes() {
|
||||
try {
|
||||
console.log('🔔 Checking best times for notifications...');
|
||||
|
||||
const currentDate = new Date();
|
||||
const today = currentDate.toISOString().split('T')[0];
|
||||
const weekStart = new Date(currentDate.setDate(currentDate.getDate() - currentDate.getDay()));
|
||||
const monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1);
|
||||
|
||||
// Check daily best times
|
||||
const dailyBestQuery = `
|
||||
WITH daily_best AS (
|
||||
SELECT
|
||||
t.player_id,
|
||||
MIN(t.recorded_time) as best_time,
|
||||
p.name as player_name,
|
||||
p.email
|
||||
FROM times t
|
||||
JOIN players p ON t.player_id = p.id
|
||||
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $1
|
||||
GROUP BY t.player_id, p.name, p.email
|
||||
),
|
||||
global_daily_best AS (
|
||||
SELECT MIN(best_time) as global_best
|
||||
FROM daily_best
|
||||
)
|
||||
SELECT
|
||||
db.player_id,
|
||||
db.player_name,
|
||||
db.email,
|
||||
db.best_time,
|
||||
gdb.global_best
|
||||
FROM daily_best db
|
||||
CROSS JOIN global_daily_best gdb
|
||||
WHERE db.best_time = gdb.global_best
|
||||
`;
|
||||
|
||||
const dailyResult = await pool.query(dailyBestQuery, [today]);
|
||||
|
||||
for (const row of dailyResult.rows) {
|
||||
console.log(`🏆 Daily best time: ${row.player_name} with ${row.best_time}`);
|
||||
// Here we would send the notification
|
||||
// For now, just log it
|
||||
}
|
||||
|
||||
// Check weekly best times
|
||||
const weeklyBestQuery = `
|
||||
WITH weekly_best AS (
|
||||
SELECT
|
||||
t.player_id,
|
||||
MIN(t.recorded_time) as best_time,
|
||||
p.name as player_name,
|
||||
p.email
|
||||
FROM times t
|
||||
JOIN players p ON t.player_id = p.id
|
||||
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1
|
||||
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2
|
||||
GROUP BY t.player_id, p.name, p.email
|
||||
),
|
||||
global_weekly_best AS (
|
||||
SELECT MIN(best_time) as global_best
|
||||
FROM weekly_best
|
||||
)
|
||||
SELECT
|
||||
wb.player_id,
|
||||
wb.player_name,
|
||||
wb.email,
|
||||
wb.best_time,
|
||||
gwb.global_best
|
||||
FROM weekly_best wb
|
||||
CROSS JOIN global_weekly_best gwb
|
||||
WHERE wb.best_time = gwb.global_best
|
||||
`;
|
||||
|
||||
const weeklyResult = await pool.query(weeklyBestQuery, [weekStart.toISOString().split('T')[0], today]);
|
||||
|
||||
for (const row of weeklyResult.rows) {
|
||||
console.log(`🏆 Weekly best time: ${row.player_name} with ${row.best_time}`);
|
||||
}
|
||||
|
||||
// Check monthly best times
|
||||
const monthlyBestQuery = `
|
||||
WITH monthly_best AS (
|
||||
SELECT
|
||||
t.player_id,
|
||||
MIN(t.recorded_time) as best_time,
|
||||
p.name as player_name,
|
||||
p.email
|
||||
FROM times t
|
||||
JOIN players p ON t.player_id = p.id
|
||||
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1
|
||||
AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2
|
||||
GROUP BY t.player_id, p.name, p.email
|
||||
),
|
||||
global_monthly_best AS (
|
||||
SELECT MIN(best_time) as global_best
|
||||
FROM monthly_best
|
||||
)
|
||||
SELECT
|
||||
mb.player_id,
|
||||
mb.player_name,
|
||||
mb.email,
|
||||
mb.best_time,
|
||||
gmb.global_best
|
||||
FROM monthly_best mb
|
||||
CROSS JOIN global_monthly_best gmb
|
||||
WHERE mb.best_time = gmb.global_best
|
||||
`;
|
||||
|
||||
const monthlyResult = await pool.query(monthlyBestQuery, [monthStart.toISOString().split('T')[0], today]);
|
||||
|
||||
for (const row of monthlyResult.rows) {
|
||||
console.log(`🏆 Monthly best time: ${row.player_name} with ${row.best_time}`);
|
||||
}
|
||||
|
||||
console.log('✅ Best time notifications check completed');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error checking best times:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule to run every day at 19:00 (7 PM)
|
||||
cron.schedule('0 19 * * *', () => {
|
||||
console.log('🕐 Running best time notifications check...');
|
||||
checkAndNotifyBestTimes();
|
||||
});
|
||||
|
||||
console.log('📅 Best time notifications scheduler started - runs daily at 19:00');
|
||||
135
scripts/iphone_notifications.js
Normal file
135
scripts/iphone_notifications.js
Normal file
@@ -0,0 +1,135 @@
|
||||
const { Pool } = require('pg');
|
||||
const webpush = require('web-push');
|
||||
|
||||
// Database connection
|
||||
const pool = new Pool({
|
||||
user: 'postgres',
|
||||
host: 'localhost',
|
||||
database: 'ninjacross',
|
||||
password: 'postgres',
|
||||
port: 5432,
|
||||
});
|
||||
|
||||
// VAPID Keys (generate with: webpush.generateVAPIDKeys())
|
||||
const vapidKeys = {
|
||||
publicKey: 'BEl62iUYgUivxIkv69yViEuiBIa40HI6F2B5L4h7Q8Y',
|
||||
privateKey: 'your-private-key-here'
|
||||
};
|
||||
|
||||
webpush.setVapidDetails(
|
||||
'mailto:admin@ninjacross.es',
|
||||
vapidKeys.publicKey,
|
||||
vapidKeys.privateKey
|
||||
);
|
||||
|
||||
// Store subscription endpoint for each player
|
||||
async function storePlayerSubscription(playerId, subscription) {
|
||||
try {
|
||||
await pool.query(`
|
||||
INSERT INTO player_subscriptions (player_id, endpoint, p256dh, auth)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (player_id)
|
||||
DO UPDATE SET
|
||||
endpoint = EXCLUDED.endpoint,
|
||||
p256dh = EXCLUDED.p256dh,
|
||||
auth = EXCLUDED.auth,
|
||||
updated_at = NOW()
|
||||
`, [
|
||||
playerId,
|
||||
subscription.endpoint,
|
||||
subscription.keys.p256dh,
|
||||
subscription.keys.auth
|
||||
]);
|
||||
console.log(`✅ Subscription stored for player ${playerId}`);
|
||||
} catch (error) {
|
||||
console.error('Error storing subscription:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Send push notification to player
|
||||
async function sendPushNotification(playerId, title, message, icon = '🏆') {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT endpoint, p256dh, auth
|
||||
FROM player_subscriptions
|
||||
WHERE player_id = $1
|
||||
`, [playerId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
console.log(`No subscription found for player ${playerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = {
|
||||
endpoint: result.rows[0].endpoint,
|
||||
keys: {
|
||||
p256dh: result.rows[0].p256dh,
|
||||
auth: result.rows[0].auth
|
||||
}
|
||||
};
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: title,
|
||||
body: message,
|
||||
icon: '/pictures/favicon.ico',
|
||||
badge: '/pictures/favicon.ico',
|
||||
data: {
|
||||
url: '/dashboard.html'
|
||||
}
|
||||
});
|
||||
|
||||
await webpush.sendNotification(subscription, payload);
|
||||
console.log(`✅ Push notification sent to player ${playerId}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending push notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Send best time notifications
|
||||
async function sendBestTimeNotifications() {
|
||||
try {
|
||||
console.log('🔔 Sending best time notifications...');
|
||||
|
||||
// Get daily best
|
||||
const dailyResult = await pool.query(`
|
||||
WITH daily_best AS (
|
||||
SELECT
|
||||
t.player_id,
|
||||
MIN(t.recorded_time) as best_time,
|
||||
CONCAT(p.firstname, ' ', p.lastname) as player_name
|
||||
FROM times t
|
||||
JOIN players p ON t.player_id = p.id
|
||||
WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
|
||||
GROUP BY t.player_id, p.firstname, p.lastname
|
||||
)
|
||||
SELECT
|
||||
player_id,
|
||||
player_name,
|
||||
best_time
|
||||
FROM daily_best
|
||||
WHERE best_time = (SELECT MIN(best_time) FROM daily_best)
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
if (dailyResult.rows.length > 0) {
|
||||
const daily = dailyResult.rows[0];
|
||||
await sendPushNotification(
|
||||
daily.player_id,
|
||||
'🏆 Tageskönig!',
|
||||
`Glückwunsch ${daily.player_name}! Du hast die beste Zeit des Tages mit ${daily.best_time} erreicht!`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('✅ Best time notifications sent');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error sending best time notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
storePlayerSubscription,
|
||||
sendPushNotification,
|
||||
sendBestTimeNotifications
|
||||
};
|
||||
Reference in New Issue
Block a user