diff --git a/data/study-users.json b/data/study-users.json index ca60ba3..2c22f50 100644 --- a/data/study-users.json +++ b/data/study-users.json @@ -1,13 +1,24 @@ { "users": [ { - "id": "e43e7217-be69-49c6-a65d-edd0e7745884", - "username": "teststudent", - "passwordHash": "3311701f9972d1c6a0933d5ccd71f83c526e24fe491334f4a9cc705407e6f7e7", - "createdAt": "2026-05-12T15:48:03.143Z", - "updatedAt": "2026-05-12T15:51:17.052Z", - "lastLoginAt": "2026-05-12T15:51:17.052Z" + "id": "9b74971e-bfdc-4e64-a8d7-87cd8f2bb5c4", + "username": "student.test@versebyverse.local", + "passwordHash": "6500cc2e717d1d646f371dba1ec918d2f2f9fddbb8576c792df69a1f2340dcd9", + "displayName": "Student Test", + "subscribeNewsletter": false, + "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" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f7686fc..cd3da01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "chart.js": "^4.4.0", + "docx": "^9.6.1", "express": "^5.2.1", "express-rate-limit": "^8.4.1", "otplib": "^13.4.0", @@ -1877,6 +1878,12 @@ "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2088,6 +2095,56 @@ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2743,6 +2800,16 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2868,6 +2935,12 @@ "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3025,6 +3098,12 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3098,6 +3177,18 @@ "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": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3122,6 +3213,15 @@ "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": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -4348,6 +4448,12 @@ "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": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -4524,6 +4630,12 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4674,6 +4786,12 @@ "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": { "version": "15.8.1", "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" } }, + "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": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -5190,12 +5323,27 @@ "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": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "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": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5269,6 +5417,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -5422,6 +5576,15 @@ "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": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -5815,6 +5978,12 @@ "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": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -6000,6 +6169,24 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "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": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 648ea8e..d09b41c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "chart.js": "^4.4.0", + "docx": "^9.6.1", "express": "^5.2.1", "express-rate-limit": "^8.4.1", "otplib": "^13.4.0", diff --git a/server.js b/server.js index 4f6aecb..dfba3b1 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { Resend } from 'resend' import qrcode from 'qrcode' +import { Document, Packer, Paragraph, HeadingLevel, TextRun, AlignmentType } from 'docx' import { sanitizeSiteContent, 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 DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.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_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.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_STUDY_USERS = 5000 +const MAX_STUDY_ENROLLMENTS_PER_USER = 100 const MAX_STUDY_NOTES_PER_USER = 500 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_TTL_MS = 30 * 24 * 60 * 60 * 1000 let visitorStats = { ...EMPTY_VISITOR_STATS } @@ -559,8 +563,8 @@ let questions = [] let questionsWritePromise = Promise.resolve() let studyUsers = [] let studyUsersWritePromise = Promise.resolve() -let studyNotesByUser = {} -let studyNotesWritePromise = Promise.resolve() +const studyNotesCache = new Map() // userId -> { [sectionId]: string } +const studyNotesWriteQueues = new Map() // userId -> Promise let downloadCounts = {} let downloadCountsWritePromise = Promise.resolve() let lastVisitorStatsWrite = { ok: true, at: null, error: null } @@ -1784,14 +1788,139 @@ function normalizeStudyUsername(value) { 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) { - 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) { 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 ', + 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: + `
` + + `

Welcome, ${escapeHtml(namePart)}.

` + + `

Your student account is ready.

` + + `

Open studies
Manage account

` + + `

Grace and peace,
Verse by Verse with Nate

` + + `
`, + }) + 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 ', + 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: + `
` + + `

Hi ${escapeHtml(namePart)},

` + + `

This confirms your study account and saved notes were deleted.

` + + `

If this was not you, please contact us immediately.

` + + `

Create a new account

` + + `

Verse by Verse with Nate

` + + `
`, + }) + if (error) console.error('[study-account] delete email send error:', error) + } catch (err) { + console.error('[study-account] delete email exception:', err) + } +} + function sanitizeStudyUsers(value) { if (!Array.isArray(value)) return [] @@ -1802,11 +1931,32 @@ function sanitizeStudyUsers(value) { const username = normalizeStudyUsername(item?.username) const passwordHash = typeof item?.passwordHash === 'string' ? item.passwordHash.trim() : '' 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) out.push({ id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), username, 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(), updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : new Date().toISOString(), lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null, @@ -1816,32 +1966,76 @@ function sanitizeStudyUsers(value) { return out.slice(0, MAX_STUDY_USERS) } -function sanitizeStudyNotes(value) { +function sanitizeUserNotes(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) return {} 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 - for (const [sectionId, note] of Object.entries(entries)) { - if (count >= MAX_STUDY_NOTES_PER_USER) break - if (!/^[a-z0-9-]{1,80}$/i.test(sectionId)) continue - if (typeof note !== 'string') continue - const trimmed = note.trim().slice(0, MAX_STUDY_NOTE_LENGTH) - if (!trimmed) continue - userNotes[sectionId] = trimmed - count += 1 - } - - out[userId] = userNotes + let count = 0 + for (const [sectionId, note] of Object.entries(value)) { + if (count >= MAX_STUDY_NOTES_PER_USER) break + if (!/^[a-z0-9-]{1,80}$/i.test(sectionId)) continue + if (typeof note !== 'string') continue + const trimmed = note.trim().slice(0, MAX_STUDY_NOTE_LENGTH) + if (!trimmed) continue + out[sectionId] = trimmed + count += 1 } - 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() { studyUsersWritePromise = studyUsersWritePromise .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() { return readFile(STUDY_USERS_FILE, 'utf8') .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) { return studyUsers.find(user => user.username === normalizeStudyUsername(username)) } @@ -1952,6 +2119,13 @@ function requireStudyAuth(req, res, 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) { if (typeof value !== 'string') return '' const trimmed = value.trim().toLowerCase() @@ -1979,15 +2153,23 @@ const studyAuthRateLimiter = rateLimit({ app.get('/api/study-auth/status', (req, res) => { 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) => { const username = normalizeStudyUsername(req.body?.username) 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)) { - 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 } @@ -1997,7 +2179,7 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => { } 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 } @@ -2006,6 +2188,10 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => { id: randomUUID(), username, passwordHash: hashStudyPassword(password), + displayName, + subscribeNewsletter: subscribe, + pendingEmailChange: null, + enrolledStudySlugs: [], createdAt: now, updatedAt: now, lastLoginAt: now, @@ -2017,9 +2203,22 @@ app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => { } 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) 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) => { @@ -2028,7 +2227,7 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => { const user = findStudyUserByUsername(username) if (!user) { - res.status(401).json({ message: 'Invalid username or password.' }) + res.status(401).json({ message: 'Invalid email or password.' }) return } @@ -2037,7 +2236,7 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => { const a = Buffer.from(submittedHash, 'utf8') const b = Buffer.from(expectedHash, 'utf8') 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 } @@ -2047,7 +2246,69 @@ app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => { const sessionToken = createStudySession(user.id) 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) => { @@ -2060,54 +2321,396 @@ app.post('/api/study-auth/logout', (req, res) => { 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 notes = studyNotesByUser[user.id] ?? {} + const notes = await loadUserNotes(user.id) 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) if (!sectionId) { res.status(400).json({ message: 'Invalid section id.' }) return } - const user = req.studyUser - const note = studyNotesByUser[user.id]?.[sectionId] ?? '' - res.json({ note }) + const noteStudySlug = getStudySlugFromNoteId(sectionId) + 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) if (!sectionId) { res.status(400).json({ message: 'Invalid section id.' }) return } - 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 note = rawNote.trim().slice(0, MAX_STUDY_NOTE_LENGTH) - - if (!studyNotesByUser[user.id]) { - studyNotesByUser[user.id] = {} - } + const notes = await loadUserNotes(user.id) if (!note) { - delete studyNotesByUser[user.id][sectionId] + delete notes[sectionId] } else { - const existingCount = Object.keys(studyNotesByUser[user.id]).length - if (!studyNotesByUser[user.id][sectionId] && existingCount >= MAX_STUDY_NOTES_PER_USER) { + const existingCount = Object.keys(notes).length + if (!notes[sectionId] && existingCount >= MAX_STUDY_NOTES_PER_USER) { res.status(400).json({ message: 'Notes limit reached for this account.' }) return } - studyNotesByUser[user.id][sectionId] = note + notes[sectionId] = note } - queueStudyNotesWrite() + studyNotesCache.set(user.id, notes) + queueUserNotesWrite(user.id) 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 ', + 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: + `
` + + `

Click the link below to confirm your new account email:

` + + `

Confirm email change

` + + `

If you did not request this change, ignore this message.

` + + `
`, + }) + 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) => { res.json({ authenticated: isValidAdminSession(req), @@ -2314,6 +2917,22 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => { .map(([reason, count]) => ({ reason, count })) 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({ totalHits: hitStats.totalHits, @@ -2354,6 +2973,12 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => { totalSubmissions: contactSubmissions.length, totalQuestions: contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length, }, + studyEnrollment: { + totalUsers: studyUsers.length, + enrolledUsers, + totalEnrollments, + enrollmentsByStudy, + }, }) }) @@ -3614,7 +4239,7 @@ Promise.all([ loadQuestionsFromDisk(), loadDraftQuestionsFromDisk(), loadStudyUsersFromDisk(), - loadStudyNotesFromDisk(), + migrateStudyNotesIfNeeded(), loadDownloadCountsFromDisk(), loadPodcastChecklistFromDisk(), refreshContentCaches(), @@ -3628,6 +4253,14 @@ Promise.all([ createBackupSnapshot('scheduled').catch(() => {}) }, 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, () => { console.log(`Portfolio app listening on http://localhost:${PORT}`) }) diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 01d2b43..4339bb3 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -69,6 +69,12 @@ export interface AdminStats { totalSubmissions: number totalQuestions: number } + studyEnrollment: { + totalUsers: number + enrolledUsers: number + totalEnrollments: number + enrollmentsByStudy: Array<{ slug: string; title: string; count: number }> + } } interface AdminAsset { @@ -2214,6 +2220,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { .slice(0, 5) const approvedCount = questions.filter(q => q.isApproved).length const answeredCount = questions.filter(q => !!q.answer?.trim()).length + const topEnrollment = stats?.studyEnrollment?.enrollmentsByStudy?.[0] return (
@@ -2257,6 +2264,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
{contactSubmissions.length} total contact submissions
+
+
{stats?.studyEnrollment?.totalEnrollments ?? 0}
+
Study Enrollments
+
+ {stats?.studyEnrollment?.enrolledUsers ?? 0} of {stats?.studyEnrollment?.totalUsers ?? 0} students enrolled + {topEnrollment ? ` · Top: ${topEnrollment.title} (${topEnrollment.count})` : ''} +
+
{downloadStats['titus-study'] ?? 0}
Titus Study Downloads
@@ -3422,7 +3437,10 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { Lesson {section.reference || section.id}

{section.title || 'Untitled section'}

- Expand to edit lesson +
+ e.stopPropagation()}>Preview + Expand to edit lesson +
@@ -3475,6 +3493,10 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {