Add study enrollment manager, profile settings, and email verification flow
This commit is contained in:
+18
-7
@@ -1,13 +1,24 @@
|
|||||||
{
|
{
|
||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
"id": "e43e7217-be69-49c6-a65d-edd0e7745884",
|
"id": "9b74971e-bfdc-4e64-a8d7-87cd8f2bb5c4",
|
||||||
"username": "teststudent",
|
"username": "student.test@versebyverse.local",
|
||||||
"passwordHash": "3311701f9972d1c6a0933d5ccd71f83c526e24fe491334f4a9cc705407e6f7e7",
|
"passwordHash": "6500cc2e717d1d646f371dba1ec918d2f2f9fddbb8576c792df69a1f2340dcd9",
|
||||||
"createdAt": "2026-05-12T15:48:03.143Z",
|
"displayName": "Student Test",
|
||||||
"updatedAt": "2026-05-12T15:51:17.052Z",
|
"subscribeNewsletter": false,
|
||||||
"lastLoginAt": "2026-05-12T15:51:17.052Z"
|
"pendingEmailChange": {
|
||||||
|
"newEmail": "student.test+new@versebyverse.local",
|
||||||
|
"tokenHash": "ce4671e74a12302744878f690af5bdfbb087c83bebdb9183c1212eacaef25554",
|
||||||
|
"expiresAt": 1779464255007,
|
||||||
|
"requestedAt": "2026-05-21T15:37:35.007Z"
|
||||||
|
},
|
||||||
|
"enrolledStudySlugs": [
|
||||||
|
"colossians"
|
||||||
|
],
|
||||||
|
"createdAt": "2026-05-21T13:05:00.000Z",
|
||||||
|
"updatedAt": "2026-05-21T15:41:16.259Z",
|
||||||
|
"lastLoginAt": "2026-05-21T15:41:16.259Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"updatedAt": "2026-05-12T15:51:17.053Z"
|
"updatedAt": "2026-05-21T15:41:16.259Z"
|
||||||
}
|
}
|
||||||
Generated
+187
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
|
"docx": "^9.6.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.4.1",
|
"express-rate-limit": "^8.4.1",
|
||||||
"otplib": "^13.4.0",
|
"otplib": "^13.4.0",
|
||||||
@@ -1877,6 +1878,12 @@
|
|||||||
"node": ">=6.6.0"
|
"node": ">=6.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2088,6 +2095,56 @@
|
|||||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/docx": {
|
||||||
|
"version": "9.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz",
|
||||||
|
"integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
|
"hash.js": "^1.1.7",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"nanoid": "^5.1.3",
|
||||||
|
"xml": "^1.0.1",
|
||||||
|
"xml-js": "^1.6.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/docx/node_modules/@types/node": {
|
||||||
|
"version": "25.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||||
|
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/docx/node_modules/nanoid": {
|
||||||
|
"version": "5.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
|
||||||
|
"integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || >=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/docx/node_modules/undici-types": {
|
||||||
|
"version": "7.24.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -2743,6 +2800,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hash.js": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"minimalistic-assert": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -2868,6 +2935,12 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -3025,6 +3098,12 @@
|
|||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -3098,6 +3177,18 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"license": "(MIT OR GPL-3.0-or-later)",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
@@ -3122,6 +3213,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -4348,6 +4448,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -4524,6 +4630,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -4674,6 +4786,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -5011,6 +5129,21 @@
|
|||||||
"react-dom": "^16.8.0 || 17.x || 18.x"
|
"react-dom": "^16.8.0 || 17.x || 18.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/remark-gfm": {
|
"node_modules/remark-gfm": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||||
@@ -5190,12 +5323,27 @@
|
|||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sax": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -5269,6 +5417,12 @@
|
|||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -5422,6 +5576,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -5815,6 +5978,12 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||||
@@ -6000,6 +6169,24 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/xml": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/xml-js": {
|
||||||
|
"version": "1.6.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||||
|
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": "^1.2.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xml-js": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
|
"docx": "^9.6.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.4.1",
|
"express-rate-limit": "^8.4.1",
|
||||||
"otplib": "^13.4.0",
|
"otplib": "^13.4.0",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import path from 'node:path'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { Resend } from 'resend'
|
import { Resend } from 'resend'
|
||||||
import qrcode from 'qrcode'
|
import qrcode from 'qrcode'
|
||||||
|
import { Document, Packer, Paragraph, HeadingLevel, TextRun, AlignmentType } from 'docx'
|
||||||
import {
|
import {
|
||||||
sanitizeSiteContent,
|
sanitizeSiteContent,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
@@ -54,7 +55,8 @@ const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json')
|
|||||||
const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json')
|
const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json')
|
||||||
const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
|
const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
|
||||||
const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json')
|
const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json')
|
||||||
const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json')
|
const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json') // legacy — kept only for one-time migration
|
||||||
|
const STUDY_NOTES_DIR = path.join(DATA_DIR, 'study-notes')
|
||||||
const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
|
const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
|
||||||
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
|
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
|
||||||
const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
|
const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
|
||||||
@@ -547,8 +549,10 @@ const titusDownloadTokens = new Map()
|
|||||||
|
|
||||||
const MAX_QUESTIONS = 1000
|
const MAX_QUESTIONS = 1000
|
||||||
const MAX_STUDY_USERS = 5000
|
const MAX_STUDY_USERS = 5000
|
||||||
|
const MAX_STUDY_ENROLLMENTS_PER_USER = 100
|
||||||
const MAX_STUDY_NOTES_PER_USER = 500
|
const MAX_STUDY_NOTES_PER_USER = 500
|
||||||
const MAX_STUDY_NOTE_LENGTH = 12000
|
const MAX_STUDY_NOTE_LENGTH = 12000
|
||||||
|
const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
|
||||||
const STUDY_SESSION_COOKIE = 'vbn_study_session'
|
const STUDY_SESSION_COOKIE = 'vbn_study_session'
|
||||||
const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
||||||
let visitorStats = { ...EMPTY_VISITOR_STATS }
|
let visitorStats = { ...EMPTY_VISITOR_STATS }
|
||||||
@@ -559,8 +563,8 @@ let questions = []
|
|||||||
let questionsWritePromise = Promise.resolve()
|
let questionsWritePromise = Promise.resolve()
|
||||||
let studyUsers = []
|
let studyUsers = []
|
||||||
let studyUsersWritePromise = Promise.resolve()
|
let studyUsersWritePromise = Promise.resolve()
|
||||||
let studyNotesByUser = {}
|
const studyNotesCache = new Map() // userId -> { [sectionId]: string }
|
||||||
let studyNotesWritePromise = Promise.resolve()
|
const studyNotesWriteQueues = new Map() // userId -> Promise
|
||||||
let downloadCounts = {}
|
let downloadCounts = {}
|
||||||
let downloadCountsWritePromise = Promise.resolve()
|
let downloadCountsWritePromise = Promise.resolve()
|
||||||
let lastVisitorStatsWrite = { ok: true, at: null, error: null }
|
let lastVisitorStatsWrite = { ok: true, at: null, error: null }
|
||||||
@@ -1784,14 +1788,139 @@ function normalizeStudyUsername(value) {
|
|||||||
return value.trim().toLowerCase()
|
return value.trim().toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStudySlug(value) {
|
||||||
|
if (typeof value !== 'string') return ''
|
||||||
|
const trimmed = value.trim().toLowerCase()
|
||||||
|
return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : ''
|
||||||
|
}
|
||||||
|
|
||||||
function isValidStudyUsername(value) {
|
function isValidStudyUsername(value) {
|
||||||
return /^[a-z0-9._-]{3,40}$/.test(value)
|
return /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(value) && value.length <= 254
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStudyCatalog() {
|
||||||
|
const fallback = [
|
||||||
|
{ slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' },
|
||||||
|
]
|
||||||
|
const content = cachedSiteContent
|
||||||
|
if (!content || typeof content !== 'object') return fallback
|
||||||
|
|
||||||
|
if (Array.isArray(content.studies) && content.studies.length > 0) {
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const study of content.studies) {
|
||||||
|
const slug = normalizeStudySlug(study?.slug)
|
||||||
|
if (!slug || seen.has(slug)) continue
|
||||||
|
seen.add(slug)
|
||||||
|
out.push({
|
||||||
|
slug,
|
||||||
|
title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug,
|
||||||
|
status: study?.status === 'planned' ? 'planned' : 'active',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (out.length > 0) return out
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnrollableStudySlug(studySlug) {
|
||||||
|
const normalized = normalizeStudySlug(studySlug)
|
||||||
|
if (!normalized) return false
|
||||||
|
return getStudyCatalog().some(study => study.slug === normalized && study.status !== 'planned')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStudyTitleBySlug(studySlug) {
|
||||||
|
const normalized = normalizeStudySlug(studySlug)
|
||||||
|
if (!normalized) return ''
|
||||||
|
const study = getStudyCatalog().find(item => item.slug === normalized)
|
||||||
|
return study?.title ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStudyUserEnrolled(user, studySlug) {
|
||||||
|
const normalized = normalizeStudySlug(studySlug)
|
||||||
|
if (!normalized || !user) return false
|
||||||
|
return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashStudyPassword(password) {
|
function hashStudyPassword(password) {
|
||||||
return createHash('sha256').update(`study-user:${String(password)}`).digest('hex')
|
return createHash('sha256').update(`study-user:${String(password)}`).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hashEmailChangeToken(token) {
|
||||||
|
return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanonicalBaseUrl() {
|
||||||
|
const configured = cachedSiteContent?.seo?.canonicalUrl ?? DEFAULT_SEO.canonicalUrl
|
||||||
|
if (typeof configured !== 'string' || !configured.trim()) return DEFAULT_SEO.canonicalUrl
|
||||||
|
return configured.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendStudyWelcomeEmail(email, displayName) {
|
||||||
|
if (!process.env.RESEND_API_KEY) return
|
||||||
|
try {
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||||||
|
const baseUrl = getCanonicalBaseUrl()
|
||||||
|
const studiesUrl = buildAbsoluteUrl(baseUrl, '/study')
|
||||||
|
const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account')
|
||||||
|
const { error } = await resend.emails.send({
|
||||||
|
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
||||||
|
to: [email],
|
||||||
|
subject: process.env.RESEND_WELCOME_SUBJECT ?? 'Welcome to Verse by Verse with Nate',
|
||||||
|
text:
|
||||||
|
`Welcome, ${namePart}.\n\n` +
|
||||||
|
`Your student account is ready.\n\n` +
|
||||||
|
`Open studies: ${studiesUrl}\n` +
|
||||||
|
`Manage account: ${accountUrl}\n\n` +
|
||||||
|
`Grace and peace,\nVerse by Verse with Nate`,
|
||||||
|
html:
|
||||||
|
`<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f1a12;">` +
|
||||||
|
`<p>Welcome, <strong>${escapeHtml(namePart)}</strong>.</p>` +
|
||||||
|
`<p>Your student account is ready.</p>` +
|
||||||
|
`<p><a href="${escapeHtml(studiesUrl)}">Open studies</a><br/><a href="${escapeHtml(accountUrl)}">Manage account</a></p>` +
|
||||||
|
`<p>Grace and peace,<br/>Verse by Verse with Nate</p>` +
|
||||||
|
`</div>`,
|
||||||
|
})
|
||||||
|
if (error) console.error('[study-signup] welcome email send error:', error)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[study-signup] welcome email exception:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendStudyAccountDeletedEmail(email, displayName) {
|
||||||
|
if (!process.env.RESEND_API_KEY) return
|
||||||
|
try {
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||||||
|
const baseUrl = getCanonicalBaseUrl()
|
||||||
|
const signupUrl = buildAbsoluteUrl(baseUrl, '/study/signup')
|
||||||
|
const { error } = await resend.emails.send({
|
||||||
|
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
||||||
|
to: [email],
|
||||||
|
subject: 'Your study account was deleted',
|
||||||
|
text:
|
||||||
|
`Hi ${namePart},\n\n` +
|
||||||
|
`This confirms your study account and saved notes were deleted.\n\n` +
|
||||||
|
`If this was not you, please contact us immediately.\n\n` +
|
||||||
|
`Create a new account anytime: ${signupUrl}\n\n` +
|
||||||
|
`Verse by Verse with Nate`,
|
||||||
|
html:
|
||||||
|
`<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f1a12;">` +
|
||||||
|
`<p>Hi ${escapeHtml(namePart)},</p>` +
|
||||||
|
`<p>This confirms your study account and saved notes were deleted.</p>` +
|
||||||
|
`<p>If this was not you, please contact us immediately.</p>` +
|
||||||
|
`<p><a href="${escapeHtml(signupUrl)}">Create a new account</a></p>` +
|
||||||
|
`<p>Verse by Verse with Nate</p>` +
|
||||||
|
`</div>`,
|
||||||
|
})
|
||||||
|
if (error) console.error('[study-account] delete email send error:', error)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[study-account] delete email exception:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeStudyUsers(value) {
|
function sanitizeStudyUsers(value) {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
|
|
||||||
@@ -1802,11 +1931,32 @@ function sanitizeStudyUsers(value) {
|
|||||||
const username = normalizeStudyUsername(item?.username)
|
const username = normalizeStudyUsername(item?.username)
|
||||||
const passwordHash = typeof item?.passwordHash === 'string' ? item.passwordHash.trim() : ''
|
const passwordHash = typeof item?.passwordHash === 'string' ? item.passwordHash.trim() : ''
|
||||||
if (!isValidStudyUsername(username) || !passwordHash || seen.has(username)) continue
|
if (!isValidStudyUsername(username) || !passwordHash || seen.has(username)) continue
|
||||||
|
const enrolledStudySlugs = Array.isArray(item?.enrolledStudySlugs)
|
||||||
|
? Array.from(new Set(item.enrolledStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
|
||||||
|
: []
|
||||||
|
const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : ''
|
||||||
|
const subscribeNewsletter = item?.subscribeNewsletter !== false
|
||||||
|
const pendingEmailChange = item?.pendingEmailChange && typeof item.pendingEmailChange === 'object' && !Array.isArray(item.pendingEmailChange)
|
||||||
|
? {
|
||||||
|
newEmail: isValidStudyUsername(normalizeStudyUsername(item.pendingEmailChange.newEmail))
|
||||||
|
? normalizeStudyUsername(item.pendingEmailChange.newEmail)
|
||||||
|
: '',
|
||||||
|
tokenHash: typeof item.pendingEmailChange.tokenHash === 'string' ? item.pendingEmailChange.tokenHash.trim() : '',
|
||||||
|
expiresAt: typeof item.pendingEmailChange.expiresAt === 'number' ? item.pendingEmailChange.expiresAt : 0,
|
||||||
|
requestedAt: typeof item.pendingEmailChange.requestedAt === 'string' ? item.pendingEmailChange.requestedAt : null,
|
||||||
|
}
|
||||||
|
: null
|
||||||
seen.add(username)
|
seen.add(username)
|
||||||
out.push({
|
out.push({
|
||||||
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||||
username,
|
username,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
|
displayName,
|
||||||
|
subscribeNewsletter,
|
||||||
|
pendingEmailChange: pendingEmailChange?.newEmail && pendingEmailChange?.tokenHash && pendingEmailChange.expiresAt > Date.now()
|
||||||
|
? pendingEmailChange
|
||||||
|
: null,
|
||||||
|
enrolledStudySlugs,
|
||||||
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
|
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
|
||||||
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : new Date().toISOString(),
|
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : new Date().toISOString(),
|
||||||
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
|
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
|
||||||
@@ -1816,32 +1966,76 @@ function sanitizeStudyUsers(value) {
|
|||||||
return out.slice(0, MAX_STUDY_USERS)
|
return out.slice(0, MAX_STUDY_USERS)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeStudyNotes(value) {
|
function sanitizeUserNotes(value) {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||||
const out = {}
|
const out = {}
|
||||||
|
|
||||||
for (const [userId, entries] of Object.entries(value)) {
|
|
||||||
if (typeof userId !== 'string' || !userId.trim()) continue
|
|
||||||
if (!entries || typeof entries !== 'object' || Array.isArray(entries)) continue
|
|
||||||
|
|
||||||
const userNotes = {}
|
|
||||||
let count = 0
|
let count = 0
|
||||||
for (const [sectionId, note] of Object.entries(entries)) {
|
for (const [sectionId, note] of Object.entries(value)) {
|
||||||
if (count >= MAX_STUDY_NOTES_PER_USER) break
|
if (count >= MAX_STUDY_NOTES_PER_USER) break
|
||||||
if (!/^[a-z0-9-]{1,80}$/i.test(sectionId)) continue
|
if (!/^[a-z0-9-]{1,80}$/i.test(sectionId)) continue
|
||||||
if (typeof note !== 'string') continue
|
if (typeof note !== 'string') continue
|
||||||
const trimmed = note.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
|
const trimmed = note.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
|
||||||
if (!trimmed) continue
|
if (!trimmed) continue
|
||||||
userNotes[sectionId] = trimmed
|
out[sectionId] = trimmed
|
||||||
count += 1
|
count += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
out[userId] = userNotes
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUserNotesFilePath(userId) {
|
||||||
|
// userId is a UUID — safe as a filename
|
||||||
|
return path.join(STUDY_NOTES_DIR, `${userId}.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUserNotes(userId) {
|
||||||
|
if (studyNotesCache.has(userId)) return studyNotesCache.get(userId)
|
||||||
|
try {
|
||||||
|
const raw = await readFile(getUserNotesFilePath(userId), 'utf8')
|
||||||
|
const notes = sanitizeUserNotes(JSON.parse(raw))
|
||||||
|
studyNotesCache.set(userId, notes)
|
||||||
|
return notes
|
||||||
|
} catch {
|
||||||
|
const notes = {}
|
||||||
|
studyNotesCache.set(userId, notes)
|
||||||
|
return notes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueUserNotesWrite(userId) {
|
||||||
|
const prev = studyNotesWriteQueues.get(userId) ?? Promise.resolve()
|
||||||
|
const next = prev
|
||||||
|
.then(async () => {
|
||||||
|
const notes = studyNotesCache.get(userId) ?? {}
|
||||||
|
await mkdir(STUDY_NOTES_DIR, { recursive: true })
|
||||||
|
await writeFile(getUserNotesFilePath(userId), JSON.stringify(notes, null, 2), 'utf8')
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(`[study-notes] failed to write notes for user ${userId}:`, err)
|
||||||
|
})
|
||||||
|
studyNotesWriteQueues.set(userId, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateStudyNotesIfNeeded() {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(STUDY_NOTES_FILE, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
const notesByUser = parsed?.notesByUser ?? {}
|
||||||
|
const userIds = Object.keys(notesByUser)
|
||||||
|
if (userIds.length === 0) return
|
||||||
|
await mkdir(STUDY_NOTES_DIR, { recursive: true })
|
||||||
|
let migrated = 0
|
||||||
|
for (const [userId, notes] of Object.entries(notesByUser)) {
|
||||||
|
const sanitized = sanitizeUserNotes(notes)
|
||||||
|
if (Object.keys(sanitized).length === 0) continue
|
||||||
|
const filePath = getUserNotesFilePath(userId)
|
||||||
|
try { await readFile(filePath, 'utf8'); continue } catch { /* doesn't exist yet */ }
|
||||||
|
await writeFile(filePath, JSON.stringify(sanitized, null, 2), 'utf8')
|
||||||
|
migrated += 1
|
||||||
|
}
|
||||||
|
if (migrated > 0) console.log(`[study-notes] migrated ${migrated} users to per-user files`)
|
||||||
|
} catch { /* no legacy file — nothing to migrate */ }
|
||||||
|
}
|
||||||
|
|
||||||
function queueStudyUsersWrite() {
|
function queueStudyUsersWrite() {
|
||||||
studyUsersWritePromise = studyUsersWritePromise
|
studyUsersWritePromise = studyUsersWritePromise
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -1857,21 +2051,6 @@ function queueStudyUsersWrite() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueStudyNotesWrite() {
|
|
||||||
studyNotesWritePromise = studyNotesWritePromise
|
|
||||||
.then(async () => {
|
|
||||||
await mkdir(DATA_DIR, { recursive: true })
|
|
||||||
await writeFile(
|
|
||||||
STUDY_NOTES_FILE,
|
|
||||||
JSON.stringify({ notesByUser: studyNotesByUser, updatedAt: new Date().toISOString() }, null, 2),
|
|
||||||
'utf8',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('[study-notes] failed to write notes:', err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadStudyUsersFromDisk() {
|
function loadStudyUsersFromDisk() {
|
||||||
return readFile(STUDY_USERS_FILE, 'utf8')
|
return readFile(STUDY_USERS_FILE, 'utf8')
|
||||||
.then(raw => {
|
.then(raw => {
|
||||||
@@ -1884,18 +2063,6 @@ function loadStudyUsersFromDisk() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadStudyNotesFromDisk() {
|
|
||||||
return readFile(STUDY_NOTES_FILE, 'utf8')
|
|
||||||
.then(raw => {
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
const source = parsed && typeof parsed === 'object' ? parsed.notesByUser : {}
|
|
||||||
studyNotesByUser = sanitizeStudyNotes(source)
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
studyNotesByUser = {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function findStudyUserByUsername(username) {
|
function findStudyUserByUsername(username) {
|
||||||
return studyUsers.find(user => user.username === normalizeStudyUsername(username))
|
return studyUsers.find(user => user.username === normalizeStudyUsername(username))
|
||||||
}
|
}
|
||||||
@@ -1952,6 +2119,13 @@ function requireStudyAuth(req, res, next) {
|
|||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStudySlugFromNoteId(sectionId) {
|
||||||
|
if (typeof sectionId !== 'string') return ''
|
||||||
|
const separatorIndex = sectionId.indexOf('--')
|
||||||
|
if (separatorIndex <= 0) return ''
|
||||||
|
return normalizeStudySlug(sectionId.slice(0, separatorIndex))
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLessonSectionId(value) {
|
function normalizeLessonSectionId(value) {
|
||||||
if (typeof value !== 'string') return ''
|
if (typeof value !== 'string') return ''
|
||||||
const trimmed = value.trim().toLowerCase()
|
const trimmed = value.trim().toLowerCase()
|
||||||
@@ -1979,15 +2153,23 @@ const studyAuthRateLimiter = rateLimit({
|
|||||||
|
|
||||||
app.get('/api/study-auth/status', (req, res) => {
|
app.get('/api/study-auth/status', (req, res) => {
|
||||||
const user = getStudyUserFromRequest(req)
|
const user = getStudyUserFromRequest(req)
|
||||||
res.json({ authenticated: Boolean(user), username: user?.username ?? '' })
|
res.json({
|
||||||
|
authenticated: Boolean(user),
|
||||||
|
username: user?.username ?? '',
|
||||||
|
displayName: user?.displayName ?? '',
|
||||||
|
subscribeNewsletter: user?.subscribeNewsletter !== false,
|
||||||
|
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
|
app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
|
||||||
const username = normalizeStudyUsername(req.body?.username)
|
const username = normalizeStudyUsername(req.body?.username)
|
||||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||||
|
const subscribe = req.body?.subscribe === true
|
||||||
|
const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : ''
|
||||||
|
|
||||||
if (!isValidStudyUsername(username)) {
|
if (!isValidStudyUsername(username)) {
|
||||||
res.status(400).json({ message: 'Username must be 3-40 characters (letters, numbers, dot, underscore, dash).' })
|
res.status(400).json({ message: 'Please enter a valid email address.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1997,7 +2179,7 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (findStudyUserByUsername(username)) {
|
if (findStudyUserByUsername(username)) {
|
||||||
res.status(409).json({ message: 'That username is already in use.' })
|
res.status(409).json({ message: 'An account with that email already exists.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2006,6 +2188,10 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
|
|||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
username,
|
username,
|
||||||
passwordHash: hashStudyPassword(password),
|
passwordHash: hashStudyPassword(password),
|
||||||
|
displayName,
|
||||||
|
subscribeNewsletter: subscribe,
|
||||||
|
pendingEmailChange: null,
|
||||||
|
enrolledStudySlugs: [],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
lastLoginAt: now,
|
lastLoginAt: now,
|
||||||
@@ -2017,9 +2203,22 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
|
|||||||
}
|
}
|
||||||
queueStudyUsersWrite()
|
queueStudyUsersWrite()
|
||||||
|
|
||||||
|
if (subscribe) {
|
||||||
|
addContactSubmission({ name: displayName || username, email: username, message: '', messageType: 'general', subscribe: true })
|
||||||
|
syncContactToResend(displayName || username, username).catch(err => console.error('[study-signup] resend sync error:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
sendStudyWelcomeEmail(username, displayName || username).catch(err => console.error('[study-signup] welcome email error:', err))
|
||||||
|
|
||||||
const sessionToken = createStudySession(user.id)
|
const sessionToken = createStudySession(user.id)
|
||||||
setStudySessionCookie(res, sessionToken)
|
setStudySessionCookie(res, sessionToken)
|
||||||
res.json({ ok: true, username: user.username })
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
subscribeNewsletter: user.subscribeNewsletter,
|
||||||
|
enrolledStudySlugs: user.enrolledStudySlugs,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
|
app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
|
||||||
@@ -2028,7 +2227,7 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
|
|||||||
const user = findStudyUserByUsername(username)
|
const user = findStudyUserByUsername(username)
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(401).json({ message: 'Invalid username or password.' })
|
res.status(401).json({ message: 'Invalid email or password.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2037,7 +2236,7 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
|
|||||||
const a = Buffer.from(submittedHash, 'utf8')
|
const a = Buffer.from(submittedHash, 'utf8')
|
||||||
const b = Buffer.from(expectedHash, 'utf8')
|
const b = Buffer.from(expectedHash, 'utf8')
|
||||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||||
res.status(401).json({ message: 'Invalid username or password.' })
|
res.status(401).json({ message: 'Invalid email or password.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2047,7 +2246,69 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
|
|||||||
|
|
||||||
const sessionToken = createStudySession(user.id)
|
const sessionToken = createStudySession(user.id)
|
||||||
setStudySessionCookie(res, sessionToken)
|
setStudySessionCookie(res, sessionToken)
|
||||||
res.json({ ok: true, username: user.username })
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName ?? '',
|
||||||
|
subscribeNewsletter: user.subscribeNewsletter !== false,
|
||||||
|
enrolledStudySlugs: user.enrolledStudySlugs ?? [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/study-enrollment', requireStudyAuth, (req, res) => {
|
||||||
|
const user = req.studyUser
|
||||||
|
res.json({
|
||||||
|
enrolledStudySlugs: Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
|
||||||
|
availableStudies: getStudyCatalog()
|
||||||
|
.filter(study => study.status !== 'planned')
|
||||||
|
.map(study => ({ slug: study.slug, title: study.title })),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => {
|
||||||
|
const user = req.studyUser
|
||||||
|
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||||
|
if (!studySlug || !isEnrollableStudySlug(studySlug)) {
|
||||||
|
res.status(404).json({ message: 'Study not found.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||||
|
if (!enrolled.includes(studySlug)) {
|
||||||
|
user.enrolledStudySlugs = [...enrolled, studySlug].slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
|
||||||
|
user.updatedAt = new Date().toISOString()
|
||||||
|
queueStudyUsersWrite()
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
studySlug,
|
||||||
|
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
|
||||||
|
enrolledStudySlugs: user.enrolledStudySlugs,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/study-enrollment/:studySlug', requireStudyAuth, (req, res) => {
|
||||||
|
const user = req.studyUser
|
||||||
|
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||||
|
if (!studySlug || !isEnrollableStudySlug(studySlug)) {
|
||||||
|
res.status(404).json({ message: 'Study not found.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||||
|
if (enrolled.includes(studySlug)) {
|
||||||
|
user.enrolledStudySlugs = enrolled.filter(slug => slug !== studySlug)
|
||||||
|
user.updatedAt = new Date().toISOString()
|
||||||
|
queueStudyUsersWrite()
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
studySlug,
|
||||||
|
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
|
||||||
|
enrolledStudySlugs: user.enrolledStudySlugs,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/study-auth/logout', (req, res) => {
|
app.post('/api/study-auth/logout', (req, res) => {
|
||||||
@@ -2060,54 +2321,396 @@ app.post('/api/study-auth/logout', (req, res) => {
|
|||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/api/study-notes', requireStudyAuth, (req, res) => {
|
app.get('/api/study-notes', requireStudyAuth, async (req, res) => {
|
||||||
const user = req.studyUser
|
const user = req.studyUser
|
||||||
const notes = studyNotesByUser[user.id] ?? {}
|
const notes = await loadUserNotes(user.id)
|
||||||
res.json({ notes })
|
res.json({ notes })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/api/study-notes/:sectionId', requireStudyAuth, (req, res) => {
|
app.get('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
|
||||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||||
if (!sectionId) {
|
if (!sectionId) {
|
||||||
res.status(400).json({ message: 'Invalid section id.' })
|
res.status(400).json({ message: 'Invalid section id.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = req.studyUser
|
const user = req.studyUser
|
||||||
const note = studyNotesByUser[user.id]?.[sectionId] ?? ''
|
const noteStudySlug = getStudySlugFromNoteId(sectionId)
|
||||||
res.json({ note })
|
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
|
||||||
|
res.status(403).json({ message: 'Please enroll in this study to access notes.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const notes = await loadUserNotes(user.id)
|
||||||
|
res.json({ note: notes[sectionId] ?? '' })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.put('/api/study-notes/:sectionId', requireStudyAuth, (req, res) => {
|
app.put('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
|
||||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||||
if (!sectionId) {
|
if (!sectionId) {
|
||||||
res.status(400).json({ message: 'Invalid section id.' })
|
res.status(400).json({ message: 'Invalid section id.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = req.studyUser
|
const user = req.studyUser
|
||||||
|
const noteStudySlug = getStudySlugFromNoteId(sectionId)
|
||||||
|
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
|
||||||
|
res.status(403).json({ message: 'Please enroll in this study to save notes.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
const rawNote = typeof req.body?.note === 'string' ? req.body.note : ''
|
const rawNote = typeof req.body?.note === 'string' ? req.body.note : ''
|
||||||
const note = rawNote.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
|
const note = rawNote.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
|
||||||
|
const notes = await loadUserNotes(user.id)
|
||||||
if (!studyNotesByUser[user.id]) {
|
|
||||||
studyNotesByUser[user.id] = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!note) {
|
if (!note) {
|
||||||
delete studyNotesByUser[user.id][sectionId]
|
delete notes[sectionId]
|
||||||
} else {
|
} else {
|
||||||
const existingCount = Object.keys(studyNotesByUser[user.id]).length
|
const existingCount = Object.keys(notes).length
|
||||||
if (!studyNotesByUser[user.id][sectionId] && existingCount >= MAX_STUDY_NOTES_PER_USER) {
|
if (!notes[sectionId] && existingCount >= MAX_STUDY_NOTES_PER_USER) {
|
||||||
res.status(400).json({ message: 'Notes limit reached for this account.' })
|
res.status(400).json({ message: 'Notes limit reached for this account.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
studyNotesByUser[user.id][sectionId] = note
|
notes[sectionId] = note
|
||||||
}
|
}
|
||||||
|
|
||||||
queueStudyNotesWrite()
|
studyNotesCache.set(user.id, notes)
|
||||||
|
queueUserNotesWrite(user.id)
|
||||||
res.json({ ok: true, note })
|
res.json({ ok: true, note })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/api/study-account/export-notes', requireStudyAuth, async (req, res) => {
|
||||||
|
const user = req.studyUser
|
||||||
|
const notes = await loadUserNotes(user.id)
|
||||||
|
|
||||||
|
// Build a lookup of sectionId -> { title, reference, studyTitle } from cached content
|
||||||
|
const sectionMeta = {}
|
||||||
|
const content = cachedSiteContent
|
||||||
|
if (content) {
|
||||||
|
const studies = Array.isArray(content.studies) && content.studies.length > 0
|
||||||
|
? content.studies
|
||||||
|
: [{ slug: 'colossians', title: 'Colossians: Rooted in Christ', sections: content.colossiansStudySections ?? [] }]
|
||||||
|
for (const study of studies) {
|
||||||
|
for (const section of (study.sections ?? [])) {
|
||||||
|
sectionMeta[`${study.slug}--${section.id}`] = {
|
||||||
|
studyTitle: study.title,
|
||||||
|
title: section.title,
|
||||||
|
reference: section.reference,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group notes by study
|
||||||
|
const byStudy = {}
|
||||||
|
for (const [noteKey, noteText] of Object.entries(notes)) {
|
||||||
|
if (!noteText?.trim()) continue
|
||||||
|
const dashIndex = noteKey.indexOf('--')
|
||||||
|
const studySlug = dashIndex >= 0 ? noteKey.slice(0, dashIndex) : 'unknown'
|
||||||
|
if (!byStudy[studySlug]) byStudy[studySlug] = []
|
||||||
|
byStudy[studySlug].push({ noteKey, noteText })
|
||||||
|
}
|
||||||
|
|
||||||
|
const docChildren = [
|
||||||
|
new Paragraph({
|
||||||
|
text: 'My Study Notes',
|
||||||
|
heading: HeadingLevel.TITLE,
|
||||||
|
}),
|
||||||
|
new Paragraph({
|
||||||
|
children: [new TextRun({ text: `Exported ${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}`, italics: true })],
|
||||||
|
spacing: { after: 400 },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [studySlug, entries] of Object.entries(byStudy)) {
|
||||||
|
const studyTitle = entries[0] ? (sectionMeta[entries[0].noteKey]?.studyTitle ?? studySlug) : studySlug
|
||||||
|
docChildren.push(
|
||||||
|
new Paragraph({ text: studyTitle, heading: HeadingLevel.HEADING_1, spacing: { before: 400 } }),
|
||||||
|
)
|
||||||
|
for (const { noteKey, noteText } of entries) {
|
||||||
|
const meta = sectionMeta[noteKey]
|
||||||
|
const lessonTitle = meta?.title ?? noteKey
|
||||||
|
const reference = meta?.reference ?? ''
|
||||||
|
docChildren.push(
|
||||||
|
new Paragraph({ text: lessonTitle, heading: HeadingLevel.HEADING_2, spacing: { before: 240 } }),
|
||||||
|
)
|
||||||
|
if (reference) {
|
||||||
|
docChildren.push(
|
||||||
|
new Paragraph({
|
||||||
|
children: [new TextRun({ text: reference, italics: true, color: '555555' })],
|
||||||
|
spacing: { after: 120 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const line of noteText.split('\n')) {
|
||||||
|
docChildren.push(
|
||||||
|
new Paragraph({ text: line.trim(), spacing: { after: 80 } }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (docChildren.length <= 2) {
|
||||||
|
docChildren.push(new Paragraph({ text: 'No notes saved yet.', spacing: { before: 200 } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new Document({
|
||||||
|
creator: 'Verse by Verse with Nate',
|
||||||
|
title: 'My Study Notes',
|
||||||
|
sections: [{ children: docChildren }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const buffer = await Packer.toBuffer(doc)
|
||||||
|
const filename = `my-study-notes-${new Date().toISOString().slice(0, 10)}.docx`
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
|
||||||
|
res.send(buffer)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/study-account/change-password', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||||||
|
const user = req.studyUser
|
||||||
|
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
|
||||||
|
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
|
||||||
|
|
||||||
|
const currentHash = hashStudyPassword(currentPassword)
|
||||||
|
const a = Buffer.from(currentHash, 'utf8')
|
||||||
|
const b = Buffer.from(user.passwordHash, 'utf8')
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||||
|
res.status(401).json({ message: 'Current password is incorrect.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8 || newPassword.length > 200) {
|
||||||
|
res.status(400).json({ message: 'New password must be 8–200 characters.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user.passwordHash = hashStudyPassword(newPassword)
|
||||||
|
user.updatedAt = new Date().toISOString()
|
||||||
|
queueStudyUsersWrite()
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/study-account/overview', requireStudyAuth, async (req, res) => {
|
||||||
|
const user = req.studyUser
|
||||||
|
const notes = await loadUserNotes(user.id)
|
||||||
|
const noteEntries = Object.entries(notes)
|
||||||
|
|
||||||
|
const studies = getStudyCatalog().map(study => {
|
||||||
|
const totalLessons = Array.isArray(cachedSiteContent?.studies)
|
||||||
|
? (cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === study.slug)?.sections?.length ?? 0)
|
||||||
|
: 0
|
||||||
|
const noteCount = noteEntries.filter(([key, value]) => key.startsWith(`${study.slug}--`) && typeof value === 'string' && value.trim()).length
|
||||||
|
return {
|
||||||
|
slug: study.slug,
|
||||||
|
title: study.title,
|
||||||
|
status: study.status,
|
||||||
|
enrolled: isStudyUserEnrolled(user, study.slug),
|
||||||
|
totalLessons,
|
||||||
|
completedLessons: noteCount,
|
||||||
|
noteCount,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
profile: {
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName ?? '',
|
||||||
|
subscribeNewsletter: user.subscribeNewsletter !== false,
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
noteCount: Object.keys(notes).length,
|
||||||
|
memberSince: user.createdAt,
|
||||||
|
lastLoginAt: user.lastLoginAt,
|
||||||
|
},
|
||||||
|
studies,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/study-account/profile', requireStudyAuth, (req, res) => {
|
||||||
|
const user = req.studyUser
|
||||||
|
const displayName = typeof req.body?.displayName === 'string' ? req.body.displayName.trim().slice(0, 80) : ''
|
||||||
|
user.displayName = displayName
|
||||||
|
user.updatedAt = new Date().toISOString()
|
||||||
|
queueStudyUsersWrite()
|
||||||
|
res.json({ ok: true, displayName: user.displayName })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/api/study-account/preferences', requireStudyAuth, (req, res) => {
|
||||||
|
const user = req.studyUser
|
||||||
|
const subscribeNewsletter = req.body?.subscribeNewsletter === true
|
||||||
|
user.subscribeNewsletter = subscribeNewsletter
|
||||||
|
user.updatedAt = new Date().toISOString()
|
||||||
|
queueStudyUsersWrite()
|
||||||
|
|
||||||
|
if (subscribeNewsletter) {
|
||||||
|
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, subscribeNewsletter: user.subscribeNewsletter })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/study-account/request-email-change', studyAuthRateLimiter, requireStudyAuth, async (req, res) => {
|
||||||
|
const user = req.studyUser
|
||||||
|
const newEmail = normalizeStudyUsername(req.body?.newEmail)
|
||||||
|
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
|
||||||
|
|
||||||
|
if (!isValidStudyUsername(newEmail)) {
|
||||||
|
res.status(400).json({ message: 'Please enter a valid email address.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEmail === user.username) {
|
||||||
|
res.status(400).json({ message: 'That is already your current email.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = findStudyUserByUsername(newEmail)
|
||||||
|
if (existing && existing.id !== user.id) {
|
||||||
|
res.status(409).json({ message: 'An account with that email already exists.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentHash = hashStudyPassword(currentPassword)
|
||||||
|
const a = Buffer.from(currentHash, 'utf8')
|
||||||
|
const b = Buffer.from(user.passwordHash, 'utf8')
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||||
|
res.status(401).json({ message: 'Current password is incorrect.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawToken = randomUUID()
|
||||||
|
const tokenHash = hashEmailChangeToken(rawToken)
|
||||||
|
const expiresAt = Date.now() + EMAIL_CHANGE_TOKEN_TTL_MS
|
||||||
|
|
||||||
|
user.pendingEmailChange = {
|
||||||
|
newEmail,
|
||||||
|
tokenHash,
|
||||||
|
expiresAt,
|
||||||
|
requestedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
user.updatedAt = new Date().toISOString()
|
||||||
|
queueStudyUsersWrite()
|
||||||
|
|
||||||
|
if (process.env.RESEND_API_KEY) {
|
||||||
|
try {
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
const baseUrl = getCanonicalBaseUrl()
|
||||||
|
const verifyUrl = buildAbsoluteUrl(baseUrl, `/study/account?verifyEmailToken=${encodeURIComponent(rawToken)}`)
|
||||||
|
const { error } = await resend.emails.send({
|
||||||
|
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
||||||
|
to: [newEmail],
|
||||||
|
subject: 'Confirm your new email address',
|
||||||
|
text:
|
||||||
|
`Use this link to confirm your new account email:\n${verifyUrl}\n\n` +
|
||||||
|
`If you did not request this change, ignore this message.`,
|
||||||
|
html:
|
||||||
|
`<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f1a12;">` +
|
||||||
|
`<p>Click the link below to confirm your new account email:</p>` +
|
||||||
|
`<p><a href="${escapeHtml(verifyUrl)}">Confirm email change</a></p>` +
|
||||||
|
`<p>If you did not request this change, ignore this message.</p>` +
|
||||||
|
`</div>`,
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
console.error('[study-account] email change send error:', error)
|
||||||
|
res.status(503).json({ message: 'Could not send verification email right now.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[study-account] email change send exception:', err)
|
||||||
|
res.status(503).json({ message: 'Could not send verification email right now.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, verificationSent: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/study-account/verify-email-change', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||||||
|
const user = req.studyUser
|
||||||
|
const token = typeof req.body?.token === 'string' ? req.body.token.trim() : ''
|
||||||
|
const pending = user.pendingEmailChange
|
||||||
|
|
||||||
|
if (!token || !pending || !pending.tokenHash) {
|
||||||
|
res.status(400).json({ message: 'No pending email change request found.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.expiresAt <= Date.now()) {
|
||||||
|
user.pendingEmailChange = null
|
||||||
|
queueStudyUsersWrite()
|
||||||
|
res.status(400).json({ message: 'This verification link has expired. Request a new email change.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const submittedHash = hashEmailChangeToken(token)
|
||||||
|
const a = Buffer.from(submittedHash, 'utf8')
|
||||||
|
const b = Buffer.from(pending.tokenHash, 'utf8')
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||||
|
res.status(400).json({ message: 'Invalid verification token.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEmail = normalizeStudyUsername(pending.newEmail)
|
||||||
|
if (!isValidStudyUsername(newEmail)) {
|
||||||
|
user.pendingEmailChange = null
|
||||||
|
queueStudyUsersWrite()
|
||||||
|
res.status(400).json({ message: 'Pending email address is invalid.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = findStudyUserByUsername(newEmail)
|
||||||
|
if (existing && existing.id !== user.id) {
|
||||||
|
user.pendingEmailChange = null
|
||||||
|
queueStudyUsersWrite()
|
||||||
|
res.status(409).json({ message: 'An account with that email already exists.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user.username = newEmail
|
||||||
|
user.pendingEmailChange = null
|
||||||
|
user.updatedAt = new Date().toISOString()
|
||||||
|
queueStudyUsersWrite()
|
||||||
|
|
||||||
|
if (user.subscribeNewsletter !== false) {
|
||||||
|
syncContactToResend(user.displayName || user.username, user.username).catch(err => console.error('[study-account] resend sync error:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, username: user.username })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/study-account/stats', requireStudyAuth, async (req, res) => {
|
||||||
|
const user = req.studyUser
|
||||||
|
const notes = await loadUserNotes(user.id)
|
||||||
|
res.json({
|
||||||
|
noteCount: Object.keys(notes).length,
|
||||||
|
memberSince: user.createdAt,
|
||||||
|
lastLoginAt: user.lastLoginAt,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/study-account', requireStudyAuth, async (req, res) => {
|
||||||
|
const user = req.studyUser
|
||||||
|
const deletedEmail = user.username
|
||||||
|
const deletedDisplayName = user.displayName || user.username
|
||||||
|
|
||||||
|
// Revoke all sessions for this user
|
||||||
|
for (const [token, session] of studySessions) {
|
||||||
|
if (session.userId === user.id) studySessions.delete(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from users list and persist
|
||||||
|
studyUsers = studyUsers.filter(u => u.id !== user.id)
|
||||||
|
queueStudyUsersWrite()
|
||||||
|
|
||||||
|
// Delete notes file
|
||||||
|
studyNotesCache.delete(user.id)
|
||||||
|
try { await unlink(getUserNotesFilePath(user.id)) } catch { /* no notes file is fine */ }
|
||||||
|
|
||||||
|
sendStudyAccountDeletedEmail(deletedEmail, deletedDisplayName).catch(err => {
|
||||||
|
console.error('[study-account] delete email error:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
clearStudySessionCookie(res)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/admin-auth/status', async (req, res) => {
|
app.get('/api/admin-auth/status', async (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
authenticated: isValidAdminSession(req),
|
authenticated: isValidAdminSession(req),
|
||||||
@@ -2314,6 +2917,22 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
|||||||
.map(([reason, count]) => ({ reason, count }))
|
.map(([reason, count]) => ({ reason, count }))
|
||||||
|
|
||||||
const recentVisitorRows = visitorStats.recentVisits.slice(0, 100)
|
const recentVisitorRows = visitorStats.recentVisits.slice(0, 100)
|
||||||
|
const enrollmentCountsBySlug = {}
|
||||||
|
for (const user of studyUsers) {
|
||||||
|
const userEnrollments = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||||
|
for (const studySlug of userEnrollments) {
|
||||||
|
enrollmentCountsBySlug[studySlug] = (enrollmentCountsBySlug[studySlug] ?? 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const enrollmentsByStudy = getStudyCatalog()
|
||||||
|
.map(study => ({
|
||||||
|
slug: study.slug,
|
||||||
|
title: study.title,
|
||||||
|
count: enrollmentCountsBySlug[study.slug] ?? 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
const enrolledUsers = studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length
|
||||||
|
const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
totalHits: hitStats.totalHits,
|
totalHits: hitStats.totalHits,
|
||||||
@@ -2354,6 +2973,12 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
|
|||||||
totalSubmissions: contactSubmissions.length,
|
totalSubmissions: contactSubmissions.length,
|
||||||
totalQuestions: contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length,
|
totalQuestions: contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length,
|
||||||
},
|
},
|
||||||
|
studyEnrollment: {
|
||||||
|
totalUsers: studyUsers.length,
|
||||||
|
enrolledUsers,
|
||||||
|
totalEnrollments,
|
||||||
|
enrollmentsByStudy,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3614,7 +4239,7 @@ Promise.all([
|
|||||||
loadQuestionsFromDisk(),
|
loadQuestionsFromDisk(),
|
||||||
loadDraftQuestionsFromDisk(),
|
loadDraftQuestionsFromDisk(),
|
||||||
loadStudyUsersFromDisk(),
|
loadStudyUsersFromDisk(),
|
||||||
loadStudyNotesFromDisk(),
|
migrateStudyNotesIfNeeded(),
|
||||||
loadDownloadCountsFromDisk(),
|
loadDownloadCountsFromDisk(),
|
||||||
loadPodcastChecklistFromDisk(),
|
loadPodcastChecklistFromDisk(),
|
||||||
refreshContentCaches(),
|
refreshContentCaches(),
|
||||||
@@ -3628,6 +4253,14 @@ Promise.all([
|
|||||||
createBackupSnapshot('scheduled').catch(() => {})
|
createBackupSnapshot('scheduled').catch(() => {})
|
||||||
}, BACKUP_INTERVAL_MS)
|
}, BACKUP_INTERVAL_MS)
|
||||||
|
|
||||||
|
// Purge expired study sessions every hour to prevent unbounded memory growth
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [token, session] of studySessions) {
|
||||||
|
if (session.expiresAt <= now) studySessions.delete(token)
|
||||||
|
}
|
||||||
|
}, 60 * 60 * 1000)
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Portfolio app listening on http://localhost:${PORT}`)
|
console.log(`Portfolio app listening on http://localhost:${PORT}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ export interface AdminStats {
|
|||||||
totalSubmissions: number
|
totalSubmissions: number
|
||||||
totalQuestions: number
|
totalQuestions: number
|
||||||
}
|
}
|
||||||
|
studyEnrollment: {
|
||||||
|
totalUsers: number
|
||||||
|
enrolledUsers: number
|
||||||
|
totalEnrollments: number
|
||||||
|
enrollmentsByStudy: Array<{ slug: string; title: string; count: number }>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminAsset {
|
interface AdminAsset {
|
||||||
@@ -2214,6 +2220,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
const approvedCount = questions.filter(q => q.isApproved).length
|
const approvedCount = questions.filter(q => q.isApproved).length
|
||||||
const answeredCount = questions.filter(q => !!q.answer?.trim()).length
|
const answeredCount = questions.filter(q => !!q.answer?.trim()).length
|
||||||
|
const topEnrollment = stats?.studyEnrollment?.enrollmentsByStudy?.[0]
|
||||||
return (
|
return (
|
||||||
<section className="admin-panel-section">
|
<section className="admin-panel-section">
|
||||||
<div className="admin-panel-head">
|
<div className="admin-panel-head">
|
||||||
@@ -2257,6 +2264,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
<div className="admin-dashboard-card-sub">{contactSubmissions.length} total contact submissions</div>
|
<div className="admin-dashboard-card-sub">{contactSubmissions.length} total contact submissions</div>
|
||||||
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('subscribers')}>View List →</button>
|
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('subscribers')}>View List →</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-dashboard-card">
|
||||||
|
<div className="admin-dashboard-card-value">{stats?.studyEnrollment?.totalEnrollments ?? 0}</div>
|
||||||
|
<div className="admin-dashboard-card-label">Study Enrollments</div>
|
||||||
|
<div className="admin-dashboard-card-sub">
|
||||||
|
{stats?.studyEnrollment?.enrolledUsers ?? 0} of {stats?.studyEnrollment?.totalUsers ?? 0} students enrolled
|
||||||
|
{topEnrollment ? ` · Top: ${topEnrollment.title} (${topEnrollment.count})` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="admin-dashboard-card">
|
<div className="admin-dashboard-card">
|
||||||
<div className="admin-dashboard-card-value">{downloadStats['titus-study'] ?? 0}</div>
|
<div className="admin-dashboard-card-value">{downloadStats['titus-study'] ?? 0}</div>
|
||||||
<div className="admin-dashboard-card-label">Titus Study Downloads</div>
|
<div className="admin-dashboard-card-label">Titus Study Downloads</div>
|
||||||
@@ -3422,7 +3437,10 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
<strong>Lesson {section.reference || section.id}</strong>
|
<strong>Lesson {section.reference || section.id}</strong>
|
||||||
<p>{section.title || 'Untitled section'}</p>
|
<p>{section.title || 'Untitled section'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<a href={`/study/${study.slug}/${section.id}`} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary" onClick={e => e.stopPropagation()}>Preview</a>
|
||||||
<span className="admin-collapsible-hint">Expand to edit lesson</span>
|
<span className="admin-collapsible-hint">Expand to edit lesson</span>
|
||||||
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="admin-collapsible-body">
|
<div className="admin-collapsible-body">
|
||||||
<div className="admin-array-row">
|
<div className="admin-array-row">
|
||||||
@@ -3475,6 +3493,10 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
<label htmlFor={`study-section-greek-${study.id}-${section.id}`}>Greek Words</label>
|
<label htmlFor={`study-section-greek-${study.id}-${section.id}`}>Greek Words</label>
|
||||||
<textarea id={`study-section-greek-${study.id}-${section.id}`} rows={4} value={(section.greekNotes ?? []).join('\n')} placeholder="One note per line" onChange={e => updateStudySection(study.id, section.id, 'greekNotes', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
|
<textarea id={`study-section-greek-${study.id}-${section.id}`} rows={4} value={(section.greekNotes ?? []).join('\n')} placeholder="One note per line" onChange={e => updateStudySection(study.id, section.id, 'greekNotes', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`study-section-focus-${study.id}-${section.id}`}>Focus Question</label>
|
||||||
|
<input id={`study-section-focus-${study.id}-${section.id}`} type="text" value={section.focusQuestion ?? ''} placeholder="Shown on the lesson card (defaults to first study question)" onChange={e => updateStudySection(study.id, section.id, 'focusQuestion', e.target.value)} />
|
||||||
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`study-section-questions-${study.id}-${section.id}`}>Study Questions</label>
|
<label htmlFor={`study-section-questions-${study.id}-${section.id}`}>Study Questions</label>
|
||||||
<textarea id={`study-section-questions-${study.id}-${section.id}`} rows={5} value={(section.studyQuestions ?? []).join('\n')} placeholder="One question per line" onChange={e => updateStudySection(study.id, section.id, 'studyQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
|
<textarea id={`study-section-questions-${study.id}-${section.id}`} rows={5} value={(section.studyQuestions ?? []).join('\n')} placeholder="One question per line" onChange={e => updateStudySection(study.id, section.id, 'studyQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
|
||||||
|
|||||||
+20
-6
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import type { ReactElement } from 'react'
|
||||||
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import AdminPage from './AdminPage'
|
import AdminPage from './AdminPage'
|
||||||
import QASection from './components/QASection'
|
import QASection from './components/QASection'
|
||||||
import ContactForm from './components/ContactForm'
|
import ContactForm from './components/ContactForm'
|
||||||
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage } from './colossiansStudy'
|
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage } from './colossiansStudy'
|
||||||
import { SpotifyIcon } from './icons'
|
import { SpotifyIcon } from './icons'
|
||||||
import type { SiteContent, ArchivedSeries, StudyProgram } from './content'
|
import type { SiteContent, ArchivedSeries, StudyProgram } from './content'
|
||||||
import { DEFAULTS } from './content'
|
import { DEFAULTS } from './content'
|
||||||
@@ -467,6 +468,7 @@ function SiteHeader({ content }: { content: SiteContent }) {
|
|||||||
<NavLink to="/episodes" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Episodes</NavLink>
|
<NavLink to="/episodes" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Episodes</NavLink>
|
||||||
<NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&A</NavLink>
|
<NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&A</NavLink>
|
||||||
<NavLink to="/study" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Studies</NavLink>
|
<NavLink to="/study" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Studies</NavLink>
|
||||||
|
<NavLink to="/study/account" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>My Account</NavLink>
|
||||||
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Downloads</NavLink>
|
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Downloads</NavLink>
|
||||||
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
|
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
|
||||||
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact</NavLink>
|
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact</NavLink>
|
||||||
@@ -1930,6 +1932,16 @@ function PreviewPage() {
|
|||||||
return <LandingPage content={content} />
|
return <LandingPage content={content} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StudyRouteFrame({ content, child }: { content: SiteContent; child: ReactElement }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SiteHeader content={content} />
|
||||||
|
{child}
|
||||||
|
<SiteFooter content={content} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [content, setContent] = useState<SiteContent>(DEFAULTS)
|
const [content, setContent] = useState<SiteContent>(DEFAULTS)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -2004,11 +2016,13 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LandingPage content={content} />} />
|
<Route path="/" element={<LandingPage content={content} />} />
|
||||||
<Route path="/start-here" element={<StartHerePage content={content} />} />
|
<Route path="/start-here" element={<StartHerePage content={content} />} />
|
||||||
<Route path="/study" element={<StudyLandingPage content={content} />} />
|
<Route path="/study" element={<StudyRouteFrame content={content} child={<StudyLandingPage content={content} />} />} />
|
||||||
<Route path="/study/signup" element={<StudySignupPage />} />
|
<Route path="/studys" element={<StudyRouteFrame content={content} child={<StudyLandingPage content={content} />} />} />
|
||||||
<Route path="/study/:studySlug" element={<ColossiansStudyIndexPage content={content} />} />
|
<Route path="/study/signup" element={<StudyRouteFrame content={content} child={<StudySignupPage />} />} />
|
||||||
<Route path="/study/:studySlug/notes" element={<ColossiansStudyNotesPage content={content} />} />
|
<Route path="/study/account" element={<StudyRouteFrame content={content} child={<StudyAccountPage />} />} />
|
||||||
<Route path="/study/:studySlug/:sectionId" element={<ColossiansStudySectionPage content={content} />} />
|
<Route path="/study/:studySlug" element={<StudyRouteFrame content={content} child={<ColossiansStudyIndexPage content={content} />} />} />
|
||||||
|
<Route path="/study/:studySlug/notes" element={<StudyRouteFrame content={content} child={<ColossiansStudyNotesPage content={content} />} />} />
|
||||||
|
<Route path="/study/:studySlug/:sectionId" element={<StudyRouteFrame content={content} child={<ColossiansStudySectionPage content={content} />} />} />
|
||||||
<Route path="/episodes" element={<EpisodesPage content={content} />} />
|
<Route path="/episodes" element={<EpisodesPage content={content} />} />
|
||||||
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
|
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
|
||||||
<Route path="/resources" element={<ResourcesPage content={content} />} />
|
<Route path="/resources" element={<ResourcesPage content={content} />} />
|
||||||
|
|||||||
+742
-44
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ export type ColossiansStudySection = {
|
|||||||
commentary: string
|
commentary: string
|
||||||
greekNotes: string[]
|
greekNotes: string[]
|
||||||
studyQuestions: string[]
|
studyQuestions: string[]
|
||||||
|
focusQuestion?: string
|
||||||
releasedAt?: string
|
releasedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user