PWA and push notifications
This commit is contained in:
parent
aada21d0a7
commit
bfcd9c3da0
115
package-lock.json
generated
115
package-lock.json
generated
@ -40,6 +40,7 @@
|
|||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -48,6 +49,7 @@
|
|||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
@ -3860,6 +3862,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
|
||||||
},
|
},
|
||||||
|
"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,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.55.0",
|
"version": "8.55.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
|
||||||
@ -4424,6 +4435,14 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@ -4629,6 +4648,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ast-types-flow": {
|
"node_modules/ast-types-flow": {
|
||||||
"version": "0.0.8",
|
"version": "0.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||||
@ -4766,6 +4796,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
||||||
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="
|
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="
|
||||||
},
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
"node_modules/bowser": {
|
"node_modules/bowser": {
|
||||||
"version": "2.14.1",
|
"version": "2.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||||
@ -4826,6 +4861,11 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
"node_modules/c12": {
|
"node_modules/c12": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||||
@ -5265,7 +5305,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
},
|
},
|
||||||
@ -5420,6 +5459,14 @@
|
|||||||
"node": ">= 0.4"
|
"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==",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/effect": {
|
"node_modules/effect": {
|
||||||
"version": "3.18.4",
|
"version": "3.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
||||||
@ -6676,11 +6723,31 @@
|
|||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-status-codes": {
|
"node_modules/http-status-codes": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
|
||||||
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="
|
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
@ -7302,6 +7369,25 @@
|
|||||||
"setimmediate": "^1.0.5"
|
"setimmediate": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"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.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@ -7782,6 +7868,11 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.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=="
|
||||||
|
},
|
||||||
"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",
|
||||||
@ -7798,7 +7889,6 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
@ -7819,8 +7909,7 @@
|
|||||||
"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",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/mysql2": {
|
"node_modules/mysql2": {
|
||||||
"version": "3.15.3",
|
"version": "3.15.3",
|
||||||
@ -10038,6 +10127,24 @@
|
|||||||
"d3-timer": "^3.0.1"
|
"d3-timer": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"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/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@ -42,6 +42,7 @@
|
|||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -50,6 +51,7 @@
|
|||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
@ -63,4 +65,4 @@
|
|||||||
"> 0.5%",
|
"> 0.5%",
|
||||||
"not ie 11"
|
"not ie 11"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,7 @@ model User {
|
|||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
gmailIntegration GmailIntegration?
|
gmailIntegration GmailIntegration?
|
||||||
emailLogs EmailLog[]
|
emailLogs EmailLog[]
|
||||||
|
pushSubscriptions PushSubscription[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@ -527,3 +528,18 @@ model ChatMessage {
|
|||||||
@@index([senderId, receiverId])
|
@@index([senderId, receiverId])
|
||||||
@@index([receiverId, senderId])
|
@@index([receiverId, senderId])
|
||||||
}
|
}
|
||||||
|
model PushSubscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
endpoint String @unique
|
||||||
|
p256dh String // Public key for encryption
|
||||||
|
auth String // Authentication secret
|
||||||
|
|
||||||
|
userAgent String? // Optional: device/browser info
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|||||||
25
public/sw.js
Normal file
25
public/sw.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
self.addEventListener('push', function (event) {
|
||||||
|
if (event.data) {
|
||||||
|
const data = event.data.json()
|
||||||
|
const options = {
|
||||||
|
body: data.body,
|
||||||
|
icon: '/icon-192.png',
|
||||||
|
badge: '/badge.png',
|
||||||
|
data: {
|
||||||
|
url: data.url || '/'
|
||||||
|
},
|
||||||
|
actions: data.actions || []
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, options)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', function (event) {
|
||||||
|
event.notification.close()
|
||||||
|
event.waitUntil(
|
||||||
|
clients.openWindow(event.notification.data.url)
|
||||||
|
)
|
||||||
|
})
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { sendPushNotification } from "@/lib/push"
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@ -26,6 +27,17 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Send push notification to receiver
|
||||||
|
try {
|
||||||
|
await sendPushNotification(receiverId, {
|
||||||
|
title: "New Encrypted Message",
|
||||||
|
body: `${session.user.name || "A user"} sent you a secure message.`,
|
||||||
|
url: "/dashboard/chat" // Correct to your chat dashboard path
|
||||||
|
})
|
||||||
|
} catch (pushError) {
|
||||||
|
console.error("Failed to send push notification", pushError)
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(message)
|
return NextResponse.json(message)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[CHAT_MESSAGES_POST]", error)
|
console.error("[CHAT_MESSAGES_POST]", error)
|
||||||
|
|||||||
66
src/app/api/notifications/subscribe/route.ts
Normal file
66
src/app/api/notifications/subscribe/route.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { handleApiError } from '@/lib/errors'
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await req.json()
|
||||||
|
|
||||||
|
if (!subscription || !subscription.endpoint || !subscription.keys) {
|
||||||
|
return NextResponse.json({ message: 'Invalid subscription data' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the subscription
|
||||||
|
await prisma.pushSubscription.upsert({
|
||||||
|
where: {
|
||||||
|
endpoint: subscription.endpoint
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
userId: session.user.id,
|
||||||
|
p256dh: subscription.keys.p256dh,
|
||||||
|
auth: subscription.keys.auth,
|
||||||
|
userAgent: req.headers.get('user-agent') || null
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: session.user.id,
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
p256dh: subscription.keys.p256dh,
|
||||||
|
auth: subscription.keys.auth,
|
||||||
|
userAgent: req.headers.get('user-agent') || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error, 'push-subscribe')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { endpoint } = await req.json()
|
||||||
|
|
||||||
|
if (!endpoint) {
|
||||||
|
return NextResponse.json({ message: 'Endpoint is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.pushSubscription.delete({
|
||||||
|
where: { endpoint }
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error, 'push-unsubscribe')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,7 +35,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
// Find invitation by token
|
// Find invitation by token
|
||||||
// TypeScript may not recognize invitation model due to cache, but it exists at runtime
|
// TypeScript may not recognize invitation model due to cache, but it exists at runtime
|
||||||
const invitation = await (prisma as any).invitation.findUnique({
|
const invitation = await prisma.invitation.findUnique({
|
||||||
where: { token: token.toUpperCase() },
|
where: { token: token.toUpperCase() },
|
||||||
include: {
|
include: {
|
||||||
store: {
|
store: {
|
||||||
@ -90,7 +90,7 @@ export async function POST(req: Request) {
|
|||||||
if (existingConnection) {
|
if (existingConnection) {
|
||||||
// Mark invitation as used even if connection exists
|
// Mark invitation as used even if connection exists
|
||||||
// TypeScript may not recognize invitation model due to cache, but it exists at runtime
|
// TypeScript may not recognize invitation model due to cache, but it exists at runtime
|
||||||
await (prisma as any).invitation.update({
|
await prisma.invitation.update({
|
||||||
where: { id: invitation.id },
|
where: { id: invitation.id },
|
||||||
data: { used: true, usedAt: new Date() }
|
data: { used: true, usedAt: new Date() }
|
||||||
})
|
})
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export async function DELETE(
|
|||||||
: vendorStores.map(s => s.id)
|
: vendorStores.map(s => s.id)
|
||||||
|
|
||||||
// Find and delete pending invitations
|
// Find and delete pending invitations
|
||||||
const pendingInvitations = await (prisma as any).invitation.findMany({
|
const pendingInvitations = await prisma.invitation.findMany({
|
||||||
where: {
|
where: {
|
||||||
storeId: { in: storeIdsToCheck },
|
storeId: { in: storeIdsToCheck },
|
||||||
supplierId: supplierId,
|
supplierId: supplierId,
|
||||||
@ -64,9 +64,9 @@ export async function DELETE(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete all pending invitations
|
// Delete all pending invitations
|
||||||
await (prisma as any).invitation.deleteMany({
|
await prisma.invitation.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: pendingInvitations.map((inv: any) => inv.id) }
|
id: { in: pendingInvitations.map((inv) => inv.id) }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -148,19 +148,6 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get vendor stores with user info for notifications
|
|
||||||
const vendorStoresWithUser = await prisma.store.findMany({
|
|
||||||
where: {
|
|
||||||
userId: session.user.id,
|
|
||||||
onboardingComplete: true
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
businessName: true,
|
|
||||||
userId: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!supplierProfile) {
|
if (!supplierProfile) {
|
||||||
return NextResponse.json({ message: "Supplier not found" }, { status: 404 })
|
return NextResponse.json({ message: "Supplier not found" }, { status: 404 })
|
||||||
}
|
}
|
||||||
@ -182,7 +169,7 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const existingInvitations = await (prisma as any).invitation.findMany({
|
const existingInvitations = await prisma.invitation.findMany({
|
||||||
where: {
|
where: {
|
||||||
storeId: { in: storeIds },
|
storeId: { in: storeIds },
|
||||||
supplierId: supplierProfile.id,
|
supplierId: supplierProfile.id,
|
||||||
@ -196,7 +183,7 @@ export async function POST(
|
|||||||
// Filter out stores that already have connections or pending invitations
|
// Filter out stores that already have connections or pending invitations
|
||||||
const connectedStoreIds = new Set([
|
const connectedStoreIds = new Set([
|
||||||
...existingConnections.map(c => c.storeId),
|
...existingConnections.map(c => c.storeId),
|
||||||
...existingInvitations.map((i: any) => i.storeId)
|
...existingInvitations.map((i) => i.storeId)
|
||||||
])
|
])
|
||||||
|
|
||||||
const storesToInvite = storesToConnect.filter(store => !connectedStoreIds.has(store.id))
|
const storesToInvite = storesToConnect.filter(store => !connectedStoreIds.has(store.id))
|
||||||
@ -224,7 +211,7 @@ export async function POST(
|
|||||||
const invitationToken = crypto.randomBytes(32).toString("hex").toUpperCase().slice(0, 16)
|
const invitationToken = crypto.randomBytes(32).toString("hex").toUpperCase().slice(0, 16)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const invitation = await (prisma as any).invitation.create({
|
const invitation = await prisma.invitation.create({
|
||||||
data: {
|
data: {
|
||||||
token: invitationToken,
|
token: invitationToken,
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
|
|||||||
@ -18,7 +18,15 @@ export const metadata: Metadata = {
|
|||||||
title: "JUNOHUB | Modern Commerce Infrastructure",
|
title: "JUNOHUB | Modern Commerce Infrastructure",
|
||||||
description: "The world's most advanced encrypted ecosystem for vendors and suppliers.",
|
description: "The world's most advanced encrypted ecosystem for vendors and suppliers.",
|
||||||
viewport: "width=device-width, initial-scale=1, maximum-scale=5",
|
viewport: "width=device-width, initial-scale=1, maximum-scale=5",
|
||||||
};
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "default",
|
||||||
|
title: "JUNOHUB",
|
||||||
|
},
|
||||||
|
formatDetection: {
|
||||||
|
telephone: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const viewport = {
|
export const viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
@ -28,6 +36,7 @@ export const viewport = {
|
|||||||
import { Providers } from "@/components/providers";
|
import { Providers } from "@/components/providers";
|
||||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
import { SmoothScroll } from "@/components/SmoothScroll";
|
import { SmoothScroll } from "@/components/SmoothScroll";
|
||||||
|
import { PWARegistration } from "@/components/PWARegistration";
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
@ -41,6 +50,7 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Providers>
|
<Providers>
|
||||||
|
<PWARegistration />
|
||||||
<SmoothScroll>
|
<SmoothScroll>
|
||||||
{children}
|
{children}
|
||||||
</SmoothScroll>
|
</SmoothScroll>
|
||||||
|
|||||||
30
src/app/manifest.ts
Normal file
30
src/app/manifest.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { MetadataRoute } from 'next'
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: 'JUNOHUB',
|
||||||
|
short_name: 'JUNOHUB',
|
||||||
|
description: 'The Unified Supply Chain Hub',
|
||||||
|
start_url: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
background_color: '#0a0e1a',
|
||||||
|
theme_color: '#0a0e1a',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icon-192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/apple-icon.png',
|
||||||
|
sizes: '180x180',
|
||||||
|
type: 'image/png',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/components/PWARegistration.tsx
Normal file
22
src/components/PWARegistration.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
export function PWARegistration() {
|
||||||
|
useEffect(() => {
|
||||||
|
if ('serviceWorker' in navigator && window.self === window.top) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register('/sw.js')
|
||||||
|
.then((registration) => {
|
||||||
|
console.log('PWA Service Worker registered with scope:', registration.scope)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('PWA Service Worker registration failed:', error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
104
src/hooks/usePushNotifications.ts
Normal file
104
src/hooks/usePushNotifications.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
|
||||||
|
|
||||||
|
export function usePushNotifications() {
|
||||||
|
const [permission, setPermission] = useState<NotificationPermission>('default')
|
||||||
|
const [isSubscribed, setIsSubscribed] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const urlBase64ToUint8Array = (base64String: string) => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkSubscription = useCallback(async () => {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
const subscription = await registration.pushManager.getSubscription()
|
||||||
|
setIsSubscribed(!!subscription)
|
||||||
|
setPermission(Notification.permission)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking push subscription:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkSubscription()
|
||||||
|
}, [checkSubscription])
|
||||||
|
|
||||||
|
const subscribe = async () => {
|
||||||
|
if (!VAPID_PUBLIC_KEY) {
|
||||||
|
console.error('VAPID public key not found')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch('/api/notifications/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(subscription)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setIsSubscribed(true)
|
||||||
|
setPermission(Notification.permission)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error subscribing to push notifications:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = async () => {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
const subscription = await registration.pushManager.getSubscription()
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
await fetch('/api/notifications/subscribe', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ endpoint: subscription.endpoint })
|
||||||
|
})
|
||||||
|
await subscription.unsubscribe()
|
||||||
|
setIsSubscribed(false)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error unsubscribing from push notifications:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
permission,
|
||||||
|
isSubscribed,
|
||||||
|
loading,
|
||||||
|
subscribe,
|
||||||
|
unsubscribe
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/lib/push.ts
Normal file
80
src/lib/push.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import webpush from 'web-push'
|
||||||
|
import { prisma } from './prisma'
|
||||||
|
import { logger } from './logger'
|
||||||
|
|
||||||
|
// Initialize VAPID keys
|
||||||
|
if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY && process.env.VAPID_SUBJECT) {
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
process.env.VAPID_SUBJECT,
|
||||||
|
process.env.VAPID_PUBLIC_KEY,
|
||||||
|
process.env.VAPID_PRIVATE_KEY
|
||||||
|
)
|
||||||
|
} else if (process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY && process.env.VAPID_SUBJECT) {
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
process.env.VAPID_SUBJECT,
|
||||||
|
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
|
||||||
|
process.env.VAPID_PRIVATE_KEY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushPayload {
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
url?: string
|
||||||
|
icon?: string
|
||||||
|
tag?: string
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPushNotification(userId: string, payload: PushPayload) {
|
||||||
|
try {
|
||||||
|
// Get all subscriptions for this user
|
||||||
|
const subscriptions = await prisma.pushSubscription.findMany({
|
||||||
|
where: { userId }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (subscriptions.length === 0) {
|
||||||
|
return { success: false, reason: 'No subscriptions found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
subscriptions.map(async (sub) => {
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(
|
||||||
|
{
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: {
|
||||||
|
p256dh: sub.p256dh,
|
||||||
|
auth: sub.auth
|
||||||
|
}
|
||||||
|
},
|
||||||
|
JSON.stringify(payload)
|
||||||
|
)
|
||||||
|
return { endpoint: sub.endpoint, success: true }
|
||||||
|
} catch (error: unknown) {
|
||||||
|
logger.error('Failed to send push notification to endpoint', {
|
||||||
|
error,
|
||||||
|
endpoint: sub.endpoint
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusCode = error && typeof error === 'object' && 'statusCode' in error
|
||||||
|
? (error as { statusCode?: number }).statusCode
|
||||||
|
: undefined
|
||||||
|
// If subscription is expired or invalid, remove it
|
||||||
|
if (statusCode === 410 || statusCode === 404) {
|
||||||
|
await prisma.pushSubscription.delete({
|
||||||
|
where: { endpoint: sub.endpoint }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { endpoint: sub.endpoint, success: false, error }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return { success: true, results }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in sendPushNotification', error)
|
||||||
|
return { success: false, error }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user