Add study enrollment manager, profile settings, and email verification flow

This commit is contained in:
nmemmert
2026-05-21 11:46:49 -04:00
parent 67f3caf40b
commit 376088a52b
8 changed files with 1702 additions and 135 deletions
+18 -7
View File
@@ -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"
} }
+187
View File
@@ -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",
+1
View File
@@ -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",
+701 -68
View File
@@ -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,30 +1966,74 @@ 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
} }
return out
out[userId] = userNotes
} }
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() {
@@ -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 8200 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}`)
}) })
+22
View File
@@ -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
View File
@@ -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&amp;A</NavLink> <NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&amp;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
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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
} }